From 539f17cdda58c2695b13a1504423f6be4e03ffc1 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 29 Dec 2025 09:13:27 +0100 Subject: [PATCH] Release v1.1.2: Improve UX, restrict HTTP to local networks, fix sync stability --- android/app/build.gradle.kts | 7 +- android/app/src/main/AndroidManifest.xml | 2 +- .../dev/dettmer/simplenotes/MainActivity.kt | 82 ++++++- .../dettmer/simplenotes/NoteEditorActivity.kt | 3 +- .../dettmer/simplenotes/SettingsActivity.kt | 200 +++++++++++++---- .../dettmer/simplenotes/sync/SyncWorker.kt | 115 +++++++++- .../simplenotes/sync/WebDavSyncService.kt | 46 +++- .../dettmer/simplenotes/utils/Constants.kt | 5 + .../simplenotes/utils/NotificationHelper.kt | 36 +++ .../dettmer/simplenotes/utils/UrlValidator.kt | 107 +++++++++ .../src/main/res/layout/activity_editor.xml | 2 +- .../app/src/main/res/layout/activity_main.xml | 20 +- .../src/main/res/layout/activity_settings.xml | 205 ++++++++++-------- .../main/res/xml/network_security_config.xml | 16 ++ .../metadata/android/de-DE/changelogs/4.txt | 12 + .../metadata/android/en-US/changelogs/4.txt | 12 + 16 files changed, 721 insertions(+), 149 deletions(-) create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 fastlane/metadata/android/de-DE/changelogs/4.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/4.txt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b40765b..c263b03 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug - versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements + versionCode = 4 // 🔥 v1.1.2: UX Fixes + CancellationException Handling + versionName = "1.1.2" // 🔥 v1.1.2: Better UX + Job Cancellation Fix testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -130,6 +130,9 @@ dependencies { // LocalBroadcastManager für UI Refresh implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + // SwipeRefreshLayout für Pull-to-Refresh + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + // Testing (bleiben so) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2eef493..fe254e1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.SimpleNotes" - android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="31"> (R.id.swipeRefreshLayout) } private fun setupToolbar() { @@ -233,10 +242,72 @@ class MainActivity : AppCompatActivity() { 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") + + 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") + swipeRefreshLayout.isRefreshing = false + 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") + showToast("✅ Bereits synchronisiert") + swipeRefreshLayout.isRefreshing = false + return@launch + } + + // Check if server is reachable + if (!syncService.isServerReachable()) { + showToast("⚠️ Server nicht erreichbar") + swipeRefreshLayout.isRefreshing = false + return@launch + } + + // Perform sync + val result = syncService.syncNotes() + + if (result.isSuccess) { + showToast("✅ ${result.syncedCount} Notizen synchronisiert") + loadNotes() + } else { + showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}") + } + } catch (e: Exception) { + Logger.e(TAG, "Pull-to-Refresh sync failed", e) + showToast("❌ Fehler: ${e.message}") + } finally { + swipeRefreshLayout.isRefreshing = false + } + } + } + + // 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 @@ -336,11 +407,18 @@ class MainActivity : AppCompatActivity() { private fun triggerManualSync() { lifecycleScope.launch { try { - showToast("Starte Synchronisation...") - // 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") + showToast("✅ Bereits synchronisiert") + return@launch + } + + showToast("Starte Synchronisation...") + // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) val isReachable = withContext(Dispatchers.IO) { syncService.isServerReachable() 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 dadaeb9..5dd9704 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt @@ -41,7 +41,8 @@ class NoteEditorActivity : AppCompatActivity() { setSupportActionBar(toolbar) supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel) + // 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon + // Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator" } // Find views 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 64d3a05..80bae4e 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -10,6 +10,7 @@ import android.util.Log import android.view.MenuItem import android.widget.Button import android.widget.EditText +import android.widget.RadioButton import android.widget.RadioGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog @@ -20,14 +21,12 @@ import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.card.MaterialCardView -import com.google.android.material.chip.Chip import com.google.android.material.color.DynamicColors import com.google.android.material.switchmaterial.SwitchMaterial import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import dev.dettmer.simplenotes.utils.UrlValidator import kotlinx.coroutines.withContext import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.NetworkMonitor @@ -49,6 +48,7 @@ class SettingsActivity : AppCompatActivity() { private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE" } + private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout private lateinit var editTextServerUrl: EditText private lateinit var editTextUsername: EditText private lateinit var editTextPassword: EditText @@ -57,7 +57,12 @@ class SettingsActivity : AppCompatActivity() { private lateinit var buttonSyncNow: Button private lateinit var buttonRestoreFromServer: Button private lateinit var textViewServerStatus: TextView - private lateinit var chipAutoSaveStatus: Chip + + // 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 @@ -68,8 +73,6 @@ class SettingsActivity : AppCompatActivity() { private lateinit var cardDeveloperProfile: MaterialCardView private lateinit var cardLicense: MaterialCardView - private var autoSaveIndicatorJob: Job? = null - private val prefs by lazy { getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) } @@ -98,6 +101,7 @@ class SettingsActivity : AppCompatActivity() { } private fun findViews() { + textInputLayoutServerUrl = findViewById(R.id.textInputLayoutServerUrl) editTextServerUrl = findViewById(R.id.editTextServerUrl) editTextUsername = findViewById(R.id.editTextUsername) editTextPassword = findViewById(R.id.editTextPassword) @@ -106,7 +110,12 @@ class SettingsActivity : AppCompatActivity() { buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer) textViewServerStatus = findViewById(R.id.textViewServerStatus) - chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus) + + // 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) @@ -119,16 +128,91 @@ class SettingsActivity : AppCompatActivity() { } private fun loadSettings() { - editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, "")) + 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 + editTextServerUrl.setText("$protocol://$hostPath") + } else { + // Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix + radioHttp.isChecked = true + 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) + // 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" + * @return Pair of (protocol, hostPath) like ("https", "example.com:8080/webdav") + */ + private fun parseUrl(url: String): Pair { + return when { + url.startsWith("https://") -> "https" to url.removePrefix("https://") + url.startsWith("http://") -> "http" to url.removePrefix("http://") + else -> "http" to url // Default to HTTP if no protocol specified + } + } + + /** + * Update the hint text below protocol selection based on selected protocol + */ + private fun updateProtocolHint() { + protocolHintText.text = if (radioHttp.isChecked) { + "HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)" + } else { + "HTTPS für sichere Verbindungen über das Internet" + } + } + + /** + * Update protocol prefix in URL field when radio button changes + * Keeps the host/path part, only changes http:// <-> https:// + */ + 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 + 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() @@ -146,24 +230,23 @@ class SettingsActivity : AppCompatActivity() { switchAutoSync.setOnCheckedChangeListener { _, isChecked -> onAutoSyncToggled(isChecked) - showAutoSaveIndicator() } + // Clear error when user starts typing again + editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + textInputLayoutServerUrl.error = null + } + override fun afterTextChanged(s: android.text.Editable?) {} + }) + // Server Status Check bei Settings-Änderung editTextServerUrl.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { checkServerStatus() - showAutoSaveIndicator() } } - - editTextUsername.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) showAutoSaveIndicator() - } - - editTextPassword.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) showAutoSaveIndicator() - } } /** @@ -258,8 +341,26 @@ class SettingsActivity : AppCompatActivity() { } 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(fullUrl) + if (!isValid) { + // Only show error in TextField (no Toast) + textInputLayoutServerUrl.isErrorEnabled = true + textInputLayoutServerUrl.error = errorMessage + return + } + } + prefs.edit().apply { - putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim()) + putString(Constants.KEY_SERVER_URL, fullUrl) putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim()) putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim()) putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked) @@ -268,6 +369,24 @@ class SettingsActivity : AppCompatActivity() { } 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(fullUrl) + if (!isValid) { + // Only show error in TextField (no Toast) + textInputLayoutServerUrl.isErrorEnabled = true + textInputLayoutServerUrl.error = errorMessage + return + } + } + lifecycleScope.launch { try { showToast("Teste Verbindung...") @@ -291,8 +410,23 @@ class SettingsActivity : AppCompatActivity() { private fun syncNow() { lifecycleScope.launch { try { - showToast("Synchronisiere...") val syncService = WebDavSyncService(this@SettingsActivity) + + // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) + if (!syncService.hasUnsyncedChanges()) { + showToast("✅ Bereits synchronisiert") + return@launch + } + + showToast("Synchronisiere...") + + // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) + if (!syncService.isServerReachable()) { + showToast("⚠️ Server nicht erreichbar") + checkServerStatus() // Server-Status aktualisieren + return@launch + } + val result = syncService.syncNotes() if (result.isSuccess) { @@ -420,32 +554,6 @@ class SettingsActivity : AppCompatActivity() { } } - private fun showAutoSaveIndicator() { - // Cancel previous job if still running - autoSaveIndicatorJob?.cancel() - - // Show saving indicator - chipAutoSaveStatus.apply { - visibility = android.view.View.VISIBLE - text = "💾 Speichere..." - setChipBackgroundColorResource(android.R.color.darker_gray) - } - - // Save settings - saveSettings() - - // Show saved confirmation after short delay - autoSaveIndicatorJob = lifecycleScope.launch { - delay(300) // Short delay to show "Speichere..." - chipAutoSaveStatus.apply { - text = "✓ Gespeichert" - setChipBackgroundColorResource(android.R.color.holo_green_light) - } - delay(2000) // Show for 2 seconds - chipAutoSaveStatus.visibility = android.view.View.GONE - } - } - private fun showRestoreConfirmation() { android.app.AlertDialog.Builder(this) .setTitle(R.string.restore_confirmation_title) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt index 62193e5..fd3345a 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -8,6 +8,7 @@ import androidx.work.WorkerParameters import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.NotificationHelper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -52,7 +53,25 @@ class SyncWorker( } if (BuildConfig.DEBUG) { - Logger.d(TAG, "📍 Step 2: Checking server reachability (Pre-Check)") + Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)") + } + + // 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen + // Spart Batterie + Netzwerk-Traffic + Server-Last + if (!syncService.hasUnsyncedChanges()) { + Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)") + Logger.d(TAG, " Saves battery, network traffic, and server load") + + if (BuildConfig.DEBUG) { + Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)") + Logger.d(TAG, "═══════════════════════════════════════") + } + + return@withContext Result.success() + } + + if (BuildConfig.DEBUG) { + Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)") } // ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync @@ -63,6 +82,9 @@ class SyncWorker( Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured") Logger.d(TAG, " This is normal in foreign WiFi or during network initialization") + // 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h) + checkAndShowSyncWarning(syncService) + if (BuildConfig.DEBUG) { Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)") Logger.d(TAG, "═══════════════════════════════════════") @@ -147,6 +169,32 @@ class SyncWorker( } Result.failure() } + } catch (e: CancellationException) { + // ⭐ Job wurde gecancelt - KEIN FEHLER! + // Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc. + if (BuildConfig.DEBUG) { + Logger.d(TAG, "═══════════════════════════════════════") + } + Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)") + Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect") + Logger.d(TAG, " This is expected Android behavior - not an error!") + + try { + // UI-Refresh trotzdem triggern (falls MainActivity geöffnet) + broadcastSyncCompleted(false, 0) + } catch (broadcastError: Exception) { + Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError) + } + + if (BuildConfig.DEBUG) { + Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)") + Logger.d(TAG, "═══════════════════════════════════════") + } + + // ⚠️ WICHTIG: Result.success() zurückgeben! + // Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen + Result.success() + } catch (e: Exception) { if (BuildConfig.DEBUG) { Logger.d(TAG, "═══════════════════════════════════════") @@ -189,4 +237,69 @@ class SyncWorker( LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count") } + + /** + * Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2) + * - Nur wenn Auto-Sync aktiviert + * - Nur wenn schon mal erfolgreich gesynct + * - Nur wenn >24h seit letztem erfolgreichen Sync + * - Throttling: Max. 1 Warnung pro 24h + */ + private fun checkAndShowSyncWarning(syncService: WebDavSyncService) { + try { + val prefs = applicationContext.getSharedPreferences( + dev.dettmer.simplenotes.utils.Constants.PREFS_NAME, + android.content.Context.MODE_PRIVATE + ) + + // Check 1: Auto-Sync aktiviert? + val autoSyncEnabled = prefs.getBoolean( + dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC, + false + ) + if (!autoSyncEnabled) { + Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed") + return + } + + // Check 2: Schon mal erfolgreich gesynct? + val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp() + if (lastSuccessfulSync == 0L) { + Logger.d(TAG, "⏭️ Never synced successfully - no warning needed") + return + } + + // Check 3: >24h seit letztem erfolgreichen Sync? + val now = System.currentTimeMillis() + val timeSinceLastSync = now - lastSuccessfulSync + if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) { + Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed") + return + } + + // Check 4: Throttling - schon Warnung in letzten 24h gezeigt? + val lastWarningShown = prefs.getLong( + dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, + 0L + ) + if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) { + Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling") + return + } + + // Zeige Warnung + val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60) + NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync) + + // Speichere Zeitpunkt der Warnung + prefs.edit() + .putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now) + .apply() + + Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h") + + } catch (e: Exception) { + Logger.e(TAG, "Failed to check/show sync warning", e) + } + } } 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 be1ba75..be95b92 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 @@ -189,6 +189,44 @@ class WebDavSyncService(private val context: Context) { return prefs.getString(Constants.KEY_SERVER_URL, null) } + /** + * 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() + + // Wenn noch nie gesynct, dann haben wir Änderungen + if (lastSyncTime == 0L) { + Logger.d(TAG, "📝 Never synced - assuming changes exist") + return@withContext true + } + + // Prüfe ob Notizen existieren die neuer sind als letzter Sync + val storage = dev.dettmer.simplenotes.storage.NotesStorage(context) + val allNotes = storage.loadAllNotes() + + val hasChanges = allNotes.any { note -> + note.updatedAt > lastSyncTime + } + + Logger.d(TAG, "📊 Unsynced changes check: $hasChanges (${allNotes.size} notes total)") + if (hasChanges) { + val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime } + Logger.d(TAG, " → $unsyncedCount notes modified since last sync") + } + + hasChanges + } catch (e: Exception) { + Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e) + // Bei Fehler lieber sync durchführen (safe default) + true + } + } + /** * Prüft ob WebDAV-Server erreichbar ist (ohne Sync zu starten) * Verwendet Socket-Check für schnelle Erreichbarkeitsprüfung @@ -481,8 +519,10 @@ class WebDavSyncService(private val context: Context) { } private fun saveLastSyncTimestamp() { + val now = System.currentTimeMillis() prefs.edit() - .putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis()) + .putLong(Constants.KEY_LAST_SYNC, now) + .putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync .apply() } @@ -490,6 +530,10 @@ class WebDavSyncService(private val context: Context) { 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 - overwrites local storage * @return RestoreResult with count of restored notes diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index 4d1dc32..e3bf7ab 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -10,6 +10,11 @@ object Constants { const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_LAST_SYNC = "last_sync_timestamp" + // 🔥 v1.1.2: Last Successful Sync Monitoring + const val KEY_LAST_SUCCESSFUL_SYNC = "last_successful_sync_time" + const val KEY_LAST_SYNC_WARNING_SHOWN = "last_sync_warning_shown_time" + const val SYNC_WARNING_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24h + // 🔥 NEU: Sync Interval Configuration const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes" const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt index 5d4136c..075d9e6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt @@ -288,4 +288,40 @@ object NotificationHelper { Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout") }, 30_000) } + + /** + * Zeigt Warnung wenn Server längere Zeit nicht erreichbar (v1.1.2) + * Throttling: Max. 1 Warnung pro 24h + */ + fun showSyncWarning(context: Context, hoursSinceLastSync: Long) { + // PendingIntent für App-Öffnung + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("⚠️ Sync-Warnung") + .setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar") + .setStyle(NotificationCompat.BigTextStyle() + .bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " + + "Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen.")) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + manager.notify(SYNC_NOTIFICATION_ID, notification) + + Logger.d(TAG, "⚠️ Showed sync warning: Server unreachable for ${hoursSinceLastSync}h") + } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt new file mode 100644 index 0000000..b9c1f54 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt @@ -0,0 +1,107 @@ +package dev.dettmer.simplenotes.utils + +import java.net.URL + +/** + * URL Validator für Network Security (v1.1.2) + * Erlaubt HTTP nur für lokale Netzwerke (RFC 1918 Private IPs) + */ +object UrlValidator { + + /** + * Prüft ob eine URL eine lokale/private Adresse ist + * Erlaubt: + * - 192.168.x.x (Class C private) + * - 10.x.x.x (Class A private) + * - 172.16.x.x - 172.31.x.x (Class B private) + * - 127.x.x.x (Localhost) + * - .local domains (mDNS/Bonjour) + */ + fun isLocalUrl(url: String): Boolean { + return try { + val parsedUrl = URL(url) + val host = parsedUrl.host.lowercase() + + // Check for .local domains (e.g., nas.local) + if (host.endsWith(".local")) { + return true + } + + // Check for localhost + if (host == "localhost" || host == "127.0.0.1") { + return true + } + + // Parse IP address if it's numeric + val ipPattern = """^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""".toRegex() + val match = ipPattern.find(host) + + if (match != null) { + val octets = match.groupValues.drop(1).map { it.toInt() } + + // Validate octets are in range 0-255 + if (octets.any { it > 255 }) { + return false + } + + val (o1, o2, o3, o4) = octets + + // Check RFC 1918 private IP ranges + return when { + // 10.0.0.0/8 (10.0.0.0 - 10.255.255.255) + o1 == 10 -> true + + // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255) + o1 == 172 && o2 in 16..31 -> true + + // 192.168.0.0/16 (192.168.0.0 - 192.168.255.255) + o1 == 192 && o2 == 168 -> true + + // 127.0.0.0/8 (Localhost) + o1 == 127 -> true + + else -> false + } + } + + // Not a recognized local address + false + } catch (e: Exception) { + // Invalid URL format + false + } + } + + /** + * Validiert ob HTTP URL erlaubt ist + * @return Pair - (isValid, errorMessage) + */ + fun validateHttpUrl(url: String): Pair { + return try { + val parsedUrl = URL(url) + + // HTTPS ist immer erlaubt + if (parsedUrl.protocol.equals("https", ignoreCase = true)) { + return Pair(true, null) + } + + // HTTP nur für lokale URLs erlaubt + if (parsedUrl.protocol.equals("http", ignoreCase = true)) { + if (isLocalUrl(url)) { + return Pair(true, null) + } else { + return Pair( + false, + "HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). " + + "Für öffentliche Server verwende bitte HTTPS." + ) + } + } + + // Anderes Protokoll + Pair(false, "Ungültiges Protokoll: ${parsedUrl.protocol}. Bitte verwende HTTP oder HTTPS.") + } catch (e: Exception) { + Pair(false, "Ungültige URL: ${e.message}") + } + } +} diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml index 9ce0a6e..41943fc 100644 --- a/android/app/src/main/res/layout/activity_editor.xml +++ b/android/app/src/main/res/layout/activity_editor.xml @@ -14,7 +14,7 @@ android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:elevation="0dp" - app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel" + app:navigationIcon="?attr/homeAsUpIndicator" app:title="@string/edit_note" app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index a2cdf4c..8a91cbe 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -24,14 +24,22 @@ - - + + app:layout_behavior="@string/appbar_scrolling_view_behavior"> + + + + + - - - - - + + + + + + + + + + + + + + + - + @@ -247,87 +287,22 @@ - - - - - - - - - - - + + android:layout_height="1dp" + android:layout_marginVertical="16dp" + android:background="?attr/colorOutlineVariant" /> - - - - - - - - -