diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 455fcab..88be83e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup alias(libs.plugins.detekt) + alias(libs.plugins.ksp) } import java.util.Properties @@ -25,13 +26,13 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - + // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility dependenciesInfo { includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK includeInBundle = false // Also disable for AAB (Google Play) } - + // Product Flavors for F-Droid and standard builds // Note: APK splits are disabled to ensure single APK output flavorDimensions += "distribution" @@ -42,7 +43,7 @@ android { // All dependencies in this project are already FOSS-compatible // No APK splits - F-Droid expects single universal APK } - + create("standard") { dimension = "distribution" // Standard builds can include Play Services in the future if needed @@ -57,7 +58,7 @@ android { if (keystorePropertiesFile.exists()) { val keystoreProperties = Properties() keystoreProperties.load(FileInputStream(keystorePropertiesFile)) - + storeFile = file(keystoreProperties.getProperty("storeFile")) storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") @@ -72,11 +73,11 @@ android { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" isDebuggable = true - + // Optionales separates Icon-Label für Debug-Builds resValue("string", "app_name_debug", "Simple Notes (Debug)") } - + release { isMinifyEnabled = true isShrinkResources = true @@ -98,12 +99,12 @@ android { buildConfig = true // Enable BuildConfig generation compose = true // v1.5.0: Jetpack Compose für Settings Redesign } - + // v1.7.0: Mock Android framework classes in unit tests (Log, etc.) testOptions { unitTests.isReturnDefaultValues = true } - + // v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard // composeCompiler { } @@ -162,6 +163,15 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) debugImplementation(libs.androidx.compose.ui.tooling) + // Koin + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + // ═══════════════════════════════════════════════════════════════════════ // 🆕 v1.8.0: Homescreen Widgets // ═══════════════════════════════════════════════════════════════════════ @@ -180,7 +190,7 @@ ktlint { outputToConsole = true ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts enableExperimentalRules = false - + filter { exclude("**/generated/**") exclude("**/build/**") @@ -196,7 +206,7 @@ detekt { allRules = false config.setFrom(files("$rootDir/config/detekt/detekt.yml")) baseline = file("$rootDir/config/detekt/baseline.xml") - + // Parallel-Verarbeitung für schnellere Checks parallel = true } @@ -205,13 +215,13 @@ detekt { // Single source of truth: F-Droid changelogs are reused in the app tasks.register("copyChangelogsToAssets") { description = "Copies F-Droid changelogs to app assets for post-update dialog" - + from("$rootDir/../fastlane/metadata/android") { include("*/changelogs/*.txt") } - + into("$projectDir/src/main/assets/changelogs") - + // Preserve directory structure: en-US/20.txt, de-DE/20.txt eachFile { val parts = relativePath.segments @@ -222,11 +232,11 @@ tasks.register("copyChangelogsToAssets") { relativePath = RelativePath(true, parts[0], parts[2]) } } - + includeEmptyDirs = false } // Run before preBuild to ensure changelogs are available tasks.named("preBuild") { dependsOn("copyChangelogsToAssets") -} \ No newline at end of file +} 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 0b2bfbb..0000000 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ /dev/null @@ -1,856 +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 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/SimpleNotesApplication.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt index 55c03f7..610e32f 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt @@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.Constants +import dev.dettmer.simplenotes.di.appModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin class SimpleNotesApplication : Application() { - + companion object { private const val TAG = "SimpleNotesApp" } - + lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity - + /** * 🌍 v1.7.1: Apply app locale to Application Context - * + * * This ensures ViewModels and other components using Application Context * get the correct locale-specific strings. */ @@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() { // This is handled by AppCompatDelegate which reads from system storage super.attachBaseContext(base) } - + override fun onCreate() { super.onCreate() - + + startKoin { + androidLogger() // Log Koin events + androidContext(this@SimpleNotesApplication) // Provide context to modules + modules(appModule) + } + val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) - + // 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization // This prevents the offline mode bug where users updating from v1.5.0 incorrectly // appear as offline even though they have a configured server migrateOfflineModeSetting(prefs) - + // File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!) if (prefs.getBoolean("file_logging_enabled", false)) { Logger.enableFileLogging(this) Logger.d(TAG, "📝 File logging enabled at Application startup") } - + Logger.d(TAG, "🚀 Application onCreate()") - + // Initialize notification channel NotificationHelper.createNotificationChannel(this) Logger.d(TAG, "✅ Notification channel created") - + // Initialize NetworkMonitor (WorkManager-based) // VORTEIL: WorkManager läuft auch ohne aktive App! networkMonitor = NetworkMonitor(applicationContext) - + // Start WorkManager periodic sync // Dies läuft im Hintergrund auch wenn App geschlossen ist networkMonitor.startMonitoring() - + Logger.d(TAG, "✅ WorkManager-based auto-sync initialized") } - + override fun onTerminate() { super.onTerminate() - + Logger.d(TAG, "🛑 Application onTerminate()") - + // WorkManager läuft weiter auch nach onTerminate! // Nur bei deaktiviertem Auto-Sync stoppen wir es } - + /** * 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0 - * - * Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel - * and NoteEditorViewModel use `true` as default, causing existing users + * + * Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel + * and NoteEditorViewModel use `true` as default, causing existing users * with configured servers to appear in offline mode after update. - * + * * Fix: Set the key BEFORE any ViewModel is initialized based on whether * a server was already configured. */ private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) { if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) { val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) - val hasServerConfig = !serverUrl.isNullOrEmpty() && - serverUrl != "http://" && + val hasServerConfig = !serverUrl.isNullOrEmpty() && + serverUrl != "http://" && serverUrl != "https://" - + // If server was configured → offlineMode = false (continue syncing) // If no server → offlineMode = true (new users / offline users) val offlineModeValue = !hasServerConfig prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply() - + Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue") } } 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 new file mode 100644 index 0000000..ee37f10 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/di/AppModule.kt @@ -0,0 +1,36 @@ +package dev.dettmer.simplenotes.di + +import android.content.Context +import androidx.room.Room +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 + +val appModule = module { + single { + Room.databaseBuilder( + androidContext(), + AppDatabase::class.java, + "notes_database" + ).build() + } + + single { get().noteDao() } + single { get().deletedNoteDao() } + + single { NotesStorage(androidContext(), get(), get()) } + + // Provide SharedPreferences + single { + androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + } + + + + 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 new file mode 100644 index 0000000..ad8e879 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/AppDatabase.kt @@ -0,0 +1,19 @@ +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 027b64f..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 @@ -3,75 +3,99 @@ 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 kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import java.io.File -class NotesStorage(private val context: Context) { - +class NotesStorage( + private val context: Context, + private val noteDao: NoteDao, + private val deletedNoteDao: DeletedNoteDao, +) { + companion object { private const val TAG = "NotesStorage" - // 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen - private val deletionTrackerMutex = Mutex() } - - private val notesDir: File = File(context.filesDir, "notes").apply { - if (!exists()) mkdirs() + + + 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 + ) + ) } - - fun saveNote(note: Note) { - val file = File(notesDir, "${note.id}.json") - file.writeText(note.toJson()) - } - - fun loadNote(id: String): Note? { - val file = File(notesDir, "$id.json") - return if (file.exists()) { - Note.fromJson(file.readText()) - } else { - null + + 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 + ) } } - - /** - * Lädt alle Notizen aus dem lokalen Speicher. - * - * 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt, - * damit der User die Sortierung konfigurieren kann. - */ - fun loadAllNotes(): List { - return notesDir.listFiles() - ?.filter { it.extension == "json" } - ?.mapNotNull { Note.fromJson(it.readText()) } - ?: emptyList() + + 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 + ) + } } - - fun deleteNote(id: String): Boolean { - val file = File(notesDir, "$id.json") - val deleted = file.delete() - + + suspend fun deleteNote(id: String): Boolean { + val deleted = noteDao.deleteNoteById(id) > 0 + if (deleted) { - Logger.d(TAG, "🗑️ Deleted note: $id") - - // Track deletion to prevent zombie notes val deviceId = DeviceIdGenerator.getDeviceId(context) - trackDeletion(id, deviceId) + deletedNoteDao.trackDeletion(DeletedNoteEntity(id, deviceId)) } - + return deleted } - - fun deleteAllNotes(): Boolean { + + suspend fun deleteAllNotes(): Boolean { return try { - val notes = loadAllNotes() - val deviceId = DeviceIdGenerator.getDeviceId(context) - + val notes = noteDao.getAllNotes() + + noteDao.deleteAllNotes() + for (note in notes) { - deleteNote(note.id) // Uses trackDeletion() automatically + val deviceId = DeviceIdGenerator.getDeviceId(context) + deletedNoteDao.trackDeletion(DeletedNoteEntity(note.id, deviceId)) } - + Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)") true } catch (e: Exception) { @@ -79,19 +103,19 @@ class NotesStorage(private val context: Context) { false } } - + // === 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() @@ -100,83 +124,64 @@ class NotesStorage(private val context: Context) { 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) { - deletionTrackerMutex.withLock { - val tracker = loadDeletionTracker() - tracker.addDeletion(noteId, deviceId) - saveDeletionTracker(tracker) - Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId") - } + 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 */ - fun trackDeletion(noteId: String, deviceId: String) { - val tracker = loadDeletionTracker() - tracker.addDeletion(noteId, deviceId) - saveDeletionTracker(tracker) + suspend fun trackDeletion(noteId: String, deviceId: String) { + deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId)) Logger.d(TAG, "📝 Tracked deletion: $noteId") } - - fun isNoteDeleted(noteId: String): Boolean { - val tracker = loadDeletionTracker() - return tracker.isDeleted(noteId) + + suspend fun isNoteDeleted(noteId: String): Boolean { + return deletedNoteDao.isNoteDeleted(noteId) } - - fun clearDeletionTracker() { - saveDeletionTracker(DeletionTracker()) + + 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 */ - fun resetAllSyncStatusToPending(): Int { - val notes = loadAllNotes() - var updatedCount = 0 - - notes.forEach { note -> - if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) { - val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING) - saveNote(updatedNote) - updatedCount++ - } - } - + suspend fun resetAllSyncStatusToPending(): Int { + var updatedCount = noteDao.updateSyncStatus(SyncStatus.SYNCED, SyncStatus.PENDING) + Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") return updatedCount } - - - fun getNotesDir(): File = notesDir } 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/dao/DeletedNoteDao.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/DeletedNoteDao.kt new file mode 100644 index 0000000..fa7eb7b --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/DeletedNoteDao.kt @@ -0,0 +1,19 @@ +package dev.dettmer.simplenotes.storage.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity + +@Dao +interface DeletedNoteDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun trackDeletion(deletedNote: DeletedNoteEntity) + + @Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)") + suspend fun isNoteDeleted(noteId: String): Boolean + + @Query("DELETE FROM deleted_notes") + suspend fun clearTracker() +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt new file mode 100644 index 0000000..af98638 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/dao/NoteDao.kt @@ -0,0 +1,29 @@ +package dev.dettmer.simplenotes.storage.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.storage.entity.NoteEntity + +@Dao +interface NoteDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveNote(note: NoteEntity) + + @Query("SELECT * FROM notes WHERE id = :id") + suspend fun getNote(id: String): NoteEntity? + + @Query("SELECT * FROM notes") + suspend fun getAllNotes(): List + + @Query("DELETE FROM notes WHERE id = :id") + suspend fun deleteNoteById(id: String): Int + + @Query("DELETE FROM notes") + suspend fun deleteAllNotes(): Int + + @Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus") + suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt new file mode 100644 index 0000000..9cfce0a --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/DeletedNoteEntity.kt @@ -0,0 +1,11 @@ +package dev.dettmer.simplenotes.storage.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "deleted_notes") +data class DeletedNoteEntity( + @PrimaryKey val noteId: String, + val deviceId: String, + val deletedAt: Long = System.currentTimeMillis() +) 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 new file mode 100644 index 0000000..70b853e --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/entity/NoteEntity.kt @@ -0,0 +1,22 @@ +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, + val title: String, + val content: String, + 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 e965120..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 @@ -44,11 +45,14 @@ import dev.dettmer.simplenotes.utils.Constants 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 * v1.5.0: Complete MainActivity Redesign with Compose - * + * * Replaces the old 805-line MainActivity.kt with a modern * Compose-based implementation featuring: * - Notes list with swipe-to-delete @@ -58,22 +62,21 @@ import kotlinx.coroutines.launch * - Design consistent with ComposeSettingsActivity */ class ComposeMainActivity : ComponentActivity() { - + companion object { private const val TAG = "ComposeMainActivity" private const val REQUEST_NOTIFICATION_PERMISSION = 1001 private const val REQUEST_SETTINGS = 1002 } - - private val viewModel: MainViewModel by viewModels() - - private val prefs by lazy { - getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) - } - + + private val viewModel: MainViewModel by viewModel() + + 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 - + /** * BroadcastReceiver for Background-Sync Completion (Periodic Sync) */ @@ -81,9 +84,9 @@ class ComposeMainActivity : ComponentActivity() { 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) { viewModel.loadNotes() @@ -91,60 +94,60 @@ class ComposeMainActivity : ComponentActivity() { } } } - + override fun onCreate(savedInstanceState: Bundle?) { // Install Splash Screen (Android 12+) installSplashScreen() - + super.onCreate(savedInstanceState) - + // Apply Dynamic Colors for Material You (Android 12+) DynamicColors.applyToActivityIfAvailable(this) - + // Enable edge-to-edge display enableEdgeToEdge() - + // Initialize Logger and enable file logging if configured Logger.init(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) { Logger.setFileLoggingEnabled(true) } - + // Clear old sync notifications on app start NotificationHelper.clearSyncNotifications(this) - + // Request notification permission (Android 13+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestNotificationPermission() } - + // v1.4.1: Migrate checklists for backwards compatibility migrateChecklistsForBackwardsCompat() - + // Setup Sync State Observer setupSyncStateObserver() - + setContent { SimpleNotesTheme { val context = LocalContext.current - + // Dialog state for delete confirmation var deleteDialogData by remember { mutableStateOf(null) } - + // Handle delete dialog events LaunchedEffect(Unit) { viewModel.showDeleteDialog.collect { data -> deleteDialogData = data } } - + // Handle toast events LaunchedEffect(Unit) { viewModel.showToast.collect { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } - + // Delete confirmation dialog deleteDialogData?.let { data -> DeleteConfirmationDialog( @@ -163,70 +166,70 @@ class ComposeMainActivity : ComponentActivity() { } ) } - + MainScreen( viewModel = viewModel, onOpenNote = { noteId -> openNoteEditor(noteId) }, onOpenSettings = { openSettings() }, onCreateNote = { noteType -> createNote(noteType) } ) - + // v1.8.0: Post-Update Changelog (shows once after update) UpdateChangelogSheet() } } } - + override fun onResume() { super.onResume() - + Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers") - + // 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks) // This ensures UI reflects current offline mode when returning from Settings viewModel.refreshOfflineModeState() - + // 🎨 v1.7.0: Refresh display mode when returning from Settings viewModel.refreshDisplayMode() - + // Register BroadcastReceiver for Background-Sync @Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional LocalBroadcastManager.getInstance(this).registerReceiver( syncCompletedReceiver, IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) ) - + Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)") - + // Reload notes viewModel.loadNotes() - + // Phase 3: Scroll to top if coming from editor (new/edited note) if (cameFromEditor) { viewModel.scrollToTop() cameFromEditor = false Logger.d(TAG, "📜 Came from editor - scrolling to top") } - + // Trigger Auto-Sync on app resume viewModel.triggerAutoSync("onResume") } - + override fun onPause() { super.onPause() - + // Unregister BroadcastReceiver @Suppress("DEPRECATION") LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) Logger.d(TAG, "📡 BroadcastReceiver unregistered") } - + private fun setupSyncStateObserver() { // 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern) SyncStateManager.syncStatus.observe(this) { status -> viewModel.updateSyncState(status) } - + // 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System) lifecycleScope.launch { SyncStateManager.syncProgress.collect { progress -> @@ -250,14 +253,14 @@ class ComposeMainActivity : ComponentActivity() { } } } - + private fun openNoteEditor(noteId: String?) { cameFromEditor = true val intent = Intent(this, ComposeNoteEditorActivity::class.java) noteId?.let { intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it) } - + // v1.5.0: Add slide animation val options = ActivityOptions.makeCustomAnimation( this, @@ -266,12 +269,12 @@ class ComposeMainActivity : ComponentActivity() { ) startActivity(intent, options.toBundle()) } - + private fun createNote(noteType: NoteType) { cameFromEditor = true val intent = Intent(this, ComposeNoteEditorActivity::class.java) intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) - + // v1.5.0: Add slide animation val options = ActivityOptions.makeCustomAnimation( this, @@ -280,7 +283,7 @@ class ComposeMainActivity : ComponentActivity() { ) startActivity(intent, options.toBundle()) } - + private fun openSettings() { val intent = Intent(this, ComposeSettingsActivity::class.java) val options = ActivityOptions.makeCustomAnimation( @@ -291,10 +294,10 @@ class ComposeMainActivity : ComponentActivity() { @Suppress("DEPRECATION") startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle()) } - + private fun requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -303,54 +306,25 @@ class ComposeMainActivity : ComponentActivity() { } } } - + /** * v1.4.1: Migrates existing checklists for backwards compatibility. */ private fun migrateChecklistsForBackwardsCompat() { - val migrationKey = "v1.4.1_checklist_migration_done" - - // 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() + viewModel.migrateChecklistsForBackwardsCompat() + } - + @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) { // Settings changed, reload notes viewModel.loadNotes() } } - + @Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts")) @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onRequestPermissionsResult( @@ -359,15 +333,15 @@ class ComposeMainActivity : ComponentActivity() { grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - + when (requestCode) { REQUEST_NOTIFICATION_PERMISSION -> { - if (grantResults.isNotEmpty() && + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show() } else { - Toast.makeText(this, - getString(R.string.toast_notifications_disabled), + Toast.makeText(this, + getString(R.string.toast_notifications_disabled), Toast.LENGTH_SHORT ).show() } @@ -389,8 +363,8 @@ private fun DeleteConfirmationDialog( AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.legacy_delete_dialog_title)) }, - text = { - Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle)) + text = { + Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle)) }, dismissButton = { TextButton(onClick = onDismiss) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt index c2561e3..de2f5b4 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt @@ -59,13 +59,14 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L /** * Main screen displaying the notes list * v1.5.0: Jetpack Compose MainActivity Redesign - * + * * Performance optimized with proper state handling: * - LazyListState for scroll control * - Scaffold FAB slot for proper z-ordering @@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( - viewModel: MainViewModel, + viewModel: MainViewModel = koinViewModel(), onOpenNote: (String?) -> Unit, onOpenSettings: () -> Unit, onCreateNote: (NoteType) -> Unit @@ -82,37 +83,37 @@ fun MainScreen( val notes by viewModel.sortedNotes.collectAsState() val syncState by viewModel.syncState.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState() - + // 🆕 v1.8.0: Einziges Banner-System val syncProgress by viewModel.syncProgress.collectAsState() - + // Multi-Select State val selectedNotes by viewModel.selectedNotes.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState() - + // 🌟 v1.6.0: Reactive offline mode state val isOfflineMode by viewModel.isOfflineMode.collectAsState() - + // 🎨 v1.7.0: Display mode (list or grid) val displayMode by viewModel.displayMode.collectAsState() - + // Delete confirmation dialog state var showBatchDeleteDialog by remember { mutableStateOf(false) } - + // 🆕 v1.8.0: Sync status legend dialog var showSyncLegend by remember { mutableStateOf(false) } - + // 🔀 v1.8.0: Sort dialog state var showSortDialog by remember { mutableStateOf(false) } val sortOption by viewModel.sortOption.collectAsState() val sortDirection by viewModel.sortDirection.collectAsState() - + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val listState = rememberLazyListState() // 🎨 v1.7.0: gridState für Staggered Grid Layout val gridState = rememberLazyStaggeredGridState() - + // ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times var timestampTicker by remember { mutableStateOf(0L) } LaunchedEffect(Unit) { @@ -121,17 +122,17 @@ fun MainScreen( timestampTicker = System.currentTimeMillis() } } - + // Compute isSyncing once val isSyncing = syncState == SyncStateManager.SyncState.SYNCING - + // 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes) // Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState() // which is called in ComposeMainActivity.onResume() when returning from Settings val hasServerConfig = viewModel.hasServerConfig() val isSyncAvailable = !isOfflineMode && hasServerConfig val canSync = isSyncAvailable && !isSyncing - + // Handle snackbar events from ViewModel LaunchedEffect(Unit) { viewModel.showSnackbar.collect { data -> @@ -147,7 +148,7 @@ fun MainScreen( } } } - + // Phase 3: Scroll to top when new note created // 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid) LaunchedEffect(scrollToTop) { @@ -160,7 +161,7 @@ fun MainScreen( viewModel.resetScrollToTop() } } - + // v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit Scaffold( topBar = { @@ -213,7 +214,7 @@ fun MainScreen( progress = syncProgress, modifier = Modifier.fillMaxWidth() ) - + // Content: Empty state or notes list if (notes.isEmpty()) { EmptyState(modifier = Modifier.weight(1f)) @@ -249,7 +250,7 @@ fun MainScreen( listState = listState, modifier = Modifier.weight(1f), onNoteClick = { note -> onOpenNote(note.id) }, - onNoteLongPress = { note -> + onNoteLongPress = { note -> // Long-press starts selection mode viewModel.startSelectionMode(note.id) }, @@ -260,7 +261,7 @@ fun MainScreen( } } } - + // FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode AnimatedVisibility( visible = !isSelectionMode, @@ -277,7 +278,7 @@ fun MainScreen( } } } - + // Batch Delete Confirmation Dialog if (showBatchDeleteDialog) { DeleteConfirmationDialog( @@ -294,14 +295,14 @@ fun MainScreen( } ) } - + // 🆕 v1.8.0: Sync Status Legend Dialog if (showSyncLegend) { SyncStatusLegendDialog( onDismiss = { showSyncLegend = false } ) } - + // 🔀 v1.8.0: Sort Dialog if (showSortDialog) { SortDialog( @@ -344,7 +345,7 @@ private fun MainTopBar( contentDescription = stringResource(R.string.sort_notes) ) } - + // 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar) if (showSyncLegend) { IconButton(onClick = onSyncLegendClick) { 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 a850d16..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 @@ -2,12 +2,15 @@ package dev.dettmer.simplenotes.ui.main import android.app.Application import android.content.Context +import android.content.SharedPreferences import androidx.lifecycle.AndroidViewModel 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 @@ -27,54 +30,56 @@ 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 * v1.5.0: Jetpack Compose MainActivity Redesign - * + * * Manages notes list, sync state, and deletion with undo. */ class MainViewModel(application: Application) : AndroidViewModel(application) { - + companion object { private const val TAG = "MainViewModel" 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 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) + // ═══════════════════════════════════════════════════════════════════════ // Notes State // ═══════════════════════════════════════════════════════════════════════ - + private val _notes = MutableStateFlow>(emptyList()) val notes: StateFlow> = _notes.asStateFlow() - + private val _pendingDeletions = MutableStateFlow>(emptySet()) val pendingDeletions: StateFlow> = _pendingDeletions.asStateFlow() - + // ═══════════════════════════════════════════════════════════════════════ // Multi-Select State (v1.5.0) // ═══════════════════════════════════════════════════════════════════════ - + private val _selectedNotes = MutableStateFlow>(emptySet()) val selectedNotes: StateFlow> = _selectedNotes.asStateFlow() - + val isSelectionMode: StateFlow = _selectedNotes .map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - + // ═══════════════════════════════════════════════════════════════════════ // 🌟 v1.6.0: Offline Mode State (reactive) // ═══════════════════════════════════════════════════════════════════════ - + private val _isOfflineMode = MutableStateFlow( prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true) ) val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow() - + /** * Refresh offline mode state from SharedPreferences * Called when returning from Settings screen (in onResume) @@ -85,16 +90,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _isOfflineMode.value = newValue Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue") } - + // ═══════════════════════════════════════════════════════════════════════ // 🎨 v1.7.0: Display Mode State // ═══════════════════════════════════════════════════════════════════════ - + private val _displayMode = MutableStateFlow( prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE ) val displayMode: StateFlow = _displayMode.asStateFlow() - + /** * Refresh display mode from SharedPreferences * Called when returning from Settings screen @@ -104,25 +109,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _displayMode.value = newValue Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue") } - + // ═══════════════════════════════════════════════════════════════════════ // 🔀 v1.8.0: Sort State // ═══════════════════════════════════════════════════════════════════════ - + private val _sortOption = MutableStateFlow( SortOption.fromPrefsValue( prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION ) ) val sortOption: StateFlow = _sortOption.asStateFlow() - + private val _sortDirection = MutableStateFlow( SortDirection.fromPrefsValue( prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION ) ) val sortDirection: StateFlow = _sortDirection.asStateFlow() - + /** * 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection. * Reagiert automatisch auf Änderungen in allen drei Flows. @@ -138,68 +143,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) - + // ═══════════════════════════════════════════════════════════════════════ // Sync State // ═══════════════════════════════════════════════════════════════════════ - + // 🆕 v1.8.0: Einziges Banner-System - SyncProgress val syncProgress: StateFlow = SyncStateManager.syncProgress - + // Intern: SyncState für PullToRefresh-Indikator private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE) val syncState: StateFlow = _syncState.asStateFlow() - + // ═══════════════════════════════════════════════════════════════════════ // UI Events // ═══════════════════════════════════════════════════════════════════════ - + private val _showToast = MutableSharedFlow() val showToast: SharedFlow = _showToast.asSharedFlow() - + private val _showDeleteDialog = MutableSharedFlow() val showDeleteDialog: SharedFlow = _showDeleteDialog.asSharedFlow() - + private val _showSnackbar = MutableSharedFlow() val showSnackbar: SharedFlow = _showSnackbar.asSharedFlow() - + // Phase 3: Scroll-to-top when new note is created private val _scrollToTop = MutableStateFlow(false) val scrollToTop: StateFlow = _scrollToTop.asStateFlow() - + // Track first note ID to detect new notes private var previousFirstNoteId: String? = null - + // ═══════════════════════════════════════════════════════════════════════ // Data Classes // ═══════════════════════════════════════════════════════════════════════ - + data class DeleteDialogData( val note: Note, val originalList: List ) - + data class SnackbarData( val message: String, val actionLabel: String, val onAction: () -> Unit ) - + // ═══════════════════════════════════════════════════════════════════════ // Initialization // ═══════════════════════════════════════════════════════════════════════ - + init { // v1.5.0 Performance: Load notes asynchronously to avoid blocking UI viewModelScope.launch(Dispatchers.IO) { loadNotesAsync() } } - + // ═══════════════════════════════════════════════════════════════════════ // Notes Actions // ═══════════════════════════════════════════════════════════════════════ - + /** * Load notes asynchronously on IO dispatcher * This prevents UI blocking during app startup @@ -208,23 +213,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val allNotes = storage.loadAllNotes() val pendingIds = _pendingDeletions.value val filteredNotes = allNotes.filter { it.id !in pendingIds } - + withContext(Dispatchers.Main) { // Phase 3: Detect if a new note was added at the top val newFirstNoteId = filteredNotes.firstOrNull()?.id - if (newFirstNoteId != null && - previousFirstNoteId != null && + if (newFirstNoteId != null && + previousFirstNoteId != null && newFirstNoteId != previousFirstNoteId) { // New note at top → trigger scroll _scrollToTop.value = true Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top") } previousFirstNoteId = newFirstNoteId - + _notes.value = filteredNotes } } - + /** * Public loadNotes - delegates to async version */ @@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { loadNotesAsync() } } - + /** * Reset scroll-to-top flag after scroll completed */ fun resetScrollToTop() { _scrollToTop.value = false } - + /** * Force scroll to top (e.g., after returning from editor) */ fun scrollToTop() { _scrollToTop.value = true } - + // ═══════════════════════════════════════════════════════════════════════ // Multi-Select Actions (v1.5.0) // ═══════════════════════════════════════════════════════════════════════ - + /** * Toggle selection of a note */ @@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _selectedNotes.value + noteId } } - + /** * Start selection mode with initial note */ fun startSelectionMode(noteId: String) { _selectedNotes.value = setOf(noteId) } - + /** * Select all notes */ fun selectAllNotes() { _selectedNotes.value = _notes.value.map { it.id }.toSet() } - + /** * Clear selection and exit selection mode */ fun clearSelection() { _selectedNotes.value = emptySet() } - + /** * Get count of selected notes */ fun getSelectedCount(): Int = _selectedNotes.value.size - + /** * 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() - + // Delete from storage selectedNotes.forEach { note -> storage.deleteNote(note.id) } - + // Clear selection clearSelection() - + // Reload notes loadNotes() - + // Show snackbar with undo val count = selectedNotes.size val message = if (deleteFromServer) { @@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } else { getString(R.string.snackbar_notes_deleted_local, count) } - + viewModelScope.launch { _showSnackbar.emit(SnackbarData( message = message, @@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { undoDeleteMultiple(selectedNotes) } )) - + @Suppress("MagicNumber") // Snackbar timing coordination // If delete from server, actually delete after a short delay // (to allow undo action before server deletion) @@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * 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() - + // Restore to storage notes.forEach { note -> storage.saveNote(note) } - + // Reload notes loadNotes() } @@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ fun onNoteLongPressDelete(note: Note) { val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) - + // Store original list for potential restore val originalList = _notes.value.toList() - + if (alwaysDeleteFromServer) { // Auto-delete without dialog deleteNoteConfirmed(note, deleteFromServer = true) @@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun onNoteSwipedToDelete(note: Note) { onNoteLongPressDelete(note) // Delegate to long-press handler } - + /** * Restore note after swipe (user cancelled dialog) */ fun restoreNoteAfterSwipe(originalList: List) { _notes.value = originalList } - + /** * 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 - + // Delete from storage storage.deleteNote(note.id) - + // Reload notes loadNotes() - + // Show snackbar with undo val message = if (deleteFromServer) { getString(R.string.snackbar_note_deleted_server, note.title) } else { getString(R.string.snackbar_note_deleted_local, note.title) } - + viewModelScope.launch { _showSnackbar.emit(SnackbarData( message = message, @@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { undoDelete(note) } )) - + @Suppress("MagicNumber") // Snackbar timing // If delete from server, actually delete after snackbar timeout if (deleteFromServer) { @@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Undo note deletion */ - fun undoDelete(note: Note) { + fun undoDelete(note: Note) = viewModelScope.launch{ // Remove from pending deletions _pendingDeletions.value = _pendingDeletions.value - note.id - + // Restore to storage storage.saveNote(note) - + // Reload notes loadNotes() } - + /** * Actually delete note from server after snackbar dismissed */ @@ -468,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val success = withContext(Dispatchers.IO) { webdavService.deleteNoteFromServer(noteId) } - + if (success) { // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server)) @@ -483,7 +488,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Delete multiple notes from server with aggregated toast * Shows single toast at the end instead of one per note @@ -493,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val webdavService = WebDavSyncService(getApplication()) var successCount = 0 var failCount = 0 - + noteIds.forEach { noteId -> try { val success = withContext(Dispatchers.IO) { @@ -507,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _pendingDeletions.value = _pendingDeletions.value - noteId } } - + // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR val message = when { failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount) @@ -525,22 +530,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Finalize deletion (remove from pending set) */ fun finalizeDeletion(noteId: String) { _pendingDeletions.value = _pendingDeletions.value - noteId } - + // ═══════════════════════════════════════════════════════════════════════ // Sync Actions // ═══════════════════════════════════════════════════════════════════════ - + fun updateSyncState(status: SyncStateManager.SyncStatus) { _syncState.value = status.state } - + /** * Trigger manual sync (from toolbar button or pull-to-refresh) * v1.7.0: Uses central canSync() gate for WiFi-only check @@ -559,14 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach) // Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren) val prefs = getApplication().getSharedPreferences( Constants.PREFS_NAME, android.content.Context.MODE_PRIVATE ) - + // 🆕 v1.7.0: Feedback wenn Sync bereits läuft // 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant if (!SyncStateManager.tryStartSync(source)) { @@ -582,10 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch) SyncStateManager.markGlobalSyncStarted(prefs) - + viewModelScope.launch { try { // Check for unsynced changes (Banner zeigt bereits PREPARING) @@ -595,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { loadNotes() return@launch } - + // Check server reachability val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ $source Sync: Server not reachable") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } - + // Perform sync val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + if (result.isSuccess) { // 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen val bannerMessage = buildString { @@ -636,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + /** * Trigger auto-sync (onResume) * Only runs if server is configured and interval has passed @@ -650,17 +655,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { Logger.d(TAG, "⏭️ onResume sync disabled - skipping") return } - + // 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen) if (!SyncStateManager.canSyncGlobally(prefs)) { return } - + // Throttling check (eigener 60s-Cooldown für onResume) if (!canTriggerAutoSync()) { return } - + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) val syncService = WebDavSyncService(getApplication()) val gateResult = syncService.canSync() @@ -672,22 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } return } - + // v1.5.0: silent=true → kein Banner bei Auto-Sync // 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar 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() - + // 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren SyncStateManager.markGlobalSyncStarted(prefs) - + viewModelScope.launch { try { // Check for unsynced changes @@ -696,23 +701,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { SyncStateManager.reset() // Silent → geht direkt auf IDLE return@launch } - + // Check server reachability val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() } - + if (!isReachable) { Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") SyncStateManager.reset() // Silent → kein Error-Banner return@launch } - + // Perform sync val result = withContext(Dispatchers.IO) { syncService.syncNotes() } - + if (result.isSuccess && result.syncedCount > 0) { Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") // 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync @@ -734,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } - + 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 } - + // ═══════════════════════════════════════════════════════════════════════ // 🔀 v1.8.0: Sortierung // ═══════════════════════════════════════════════════════════════════════ - + /** * 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung. */ @@ -768,13 +773,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { SortOption.NOTE_TYPE -> compareBy { it.noteType.ordinal } .thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen } - + return when (direction) { SortDirection.ASCENDING -> notes.sortedWith(comparator) SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed()) } } - + /** * 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences. */ @@ -783,7 +788,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply() Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}") } - + /** * 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences. */ @@ -792,7 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply() Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}") } - + /** * 🔀 v1.8.0: Toggelt die Sortierrichtung. */ @@ -800,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val newDirection = _sortDirection.value.toggle() setSortDirection(newDirection) } - + // ═══════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════ - + private fun getString(resId: Int): String = getApplication().getString(resId) - - private fun getString(resId: Int, vararg formatArgs: Any): String = + + private fun getString(resId: Int, vararg formatArgs: Any): String = getApplication().getString(resId, *formatArgs) - + fun isServerConfigured(): Boolean { // 🌟 v1.6.0: Use reactive offline mode state if (_isOfflineMode.value) { @@ -818,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://" } - + /** * 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode) * Used for determining if sync would be available when offline mode is disabled @@ -827,4 +832,37 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { 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/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt deleted file mode 100644 index 9b9b9c4..0000000 --- a/android/app/src/test/java/dev/dettmer/simplenotes/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.dettmer.simplenotes - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 255babb..df669e7 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -16,6 +16,9 @@ composeBom = "2026.01.00" navigationCompose = "2.7.6" lifecycleRuntimeCompose = "2.7.0" activityCompose = "1.8.2" +room = "2.6.1" +ksp = "2.0.21-1.0.27" +koin = "3.5.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name = androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +# Room Database +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# Core Koin for Kotlin projects +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +# Koin for Jetpack Compose integration +koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }