Merge release v1.1.2: UX improvements, HTTP restriction & stability fixes

Release v1.1.2: UX-Verbesserungen, HTTP-Restriktion & Stabilitätsfixes
This commit is contained in:
Inventory69
2025-12-29 09:26:10 +01:00
committed by GitHub
16 changed files with 721 additions and 149 deletions

View File

@@ -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)

View File

@@ -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">
<activity
android:name=".MainActivity"

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
class MainActivity : AppCompatActivity() {
@@ -41,6 +42,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
@@ -152,6 +154,12 @@ class MainActivity : AppCompatActivity() {
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")
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
@@ -220,6 +228,7 @@ class MainActivity : AppCompatActivity() {
emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(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()

View File

@@ -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

View File

@@ -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<String, String> {
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)

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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<Boolean, String?> - (isValid, errorMessage)
*/
fun validateHttpUrl(url: String): Pair<Boolean, String?> {
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}")
}
}
}

View File

@@ -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" />

View File

@@ -24,14 +24,22 @@
</com.google.android.material.appbar.AppBarLayout>
<!-- RecyclerView mit größerem Padding für Material 3 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewNotes"
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- RecyclerView mit größerem Padding für Material 3 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewNotes"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Material 3 Empty State Card -->
<com.google.android.material.card.MaterialCardView

View File

@@ -30,17 +30,6 @@
android:orientation="vertical"
android:padding="16dp">
<!-- Auto-Save Status Indicator -->
<com.google.android.material.chip.Chip
android:id="@+id/chipAutoSaveStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp"
android:visibility="gone"
android:textSize="12sp"
app:chipIconEnabled="false" />
<!-- Material 3 Card: Server Configuration -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
@@ -63,15 +52,65 @@
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- Server URL with Icon -->
<com.google.android.material.textfield.TextInputLayout
<!-- Protocol Selection -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_url"
android:text="Verbindungstyp"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="8dp" />
<RadioGroup
android:id="@+id/protocolRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/radioHttp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🏠 Intern (HTTP)"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:checked="false" />
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/radioHttps"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🌐 Extern (HTTPS)"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:checked="true" />
</RadioGroup>
<!-- Helper Text for Protocol Selection -->
<TextView
android:id="@+id/protocolHintText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Server URL with Icon -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayoutServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Server-Adresse"
android:layout_marginBottom="12dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_menu_compass"
app:endIconMode="clear_text"
app:helperText="z.B. http://192.168.0.188:8080/webdav"
app:helperTextEnabled="true"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
@@ -185,7 +224,7 @@
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Auto-Sync Settings -->
<!-- Material 3 Card: Synchronisation Settings (Auto-Sync + Interval) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -230,6 +269,7 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="horizontal"
android:gravity="center_vertical">
@@ -247,87 +287,22 @@
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_restore_title"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
android:layout_height="1dp"
android:layout_marginVertical="16dp"
android:background="?attr/colorOutlineVariant" />
<!-- Warning Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/backup_restore_warning"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnErrorContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Restore Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_from_server"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Sync Interval Configuration -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<!-- Sync Interval Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Intervall"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" />
<!-- Info Card -->
<!-- Interval Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -412,6 +387,60 @@
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_restore_title"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Warning Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/backup_restore_warning"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnErrorContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Restore Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_from_server"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: About Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow HTTP for all connections during development/testing -->
<!-- Production validation happens in UrlValidator.kt to restrict HTTP to:
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
- .local domains (mDNS/Bonjour)
This permissive config is necessary because Android's Network Security Config
doesn't support IP-based rules, only domain patterns.
We handle security through application-level validation instead. -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,12 @@
v1.1.2 - UX & Performance
• "Job was cancelled" Fehler behoben
• Zurück-Pfeil statt X im Editor
• Pull-to-Refresh für manuellen Sync
• HTTP/HTTPS Protokoll-Auswahl (Radio Buttons)
• Inline Fehler-Anzeige (keine Toast-Spam)
• Settings gruppiert (Auto-Sync & Intervall)
• Sync nur bei tatsächlichen Änderungen (spart Batterie)
• 24h Server-Offline Warnung
• HTTP nur für lokale Netzwerke (RFC 1918 IPs)
• Auto-Save Benachrichtigungen entfernt

View File

@@ -0,0 +1,12 @@
v1.1.2 - UX & Performance
• Fixed "Job was cancelled" error notifications
• Back arrow instead of X in editor
• Pull-to-Refresh for manual sync
• HTTP/HTTPS protocol selector (radio buttons)
• Inline error display (no toast spam)
• Grouped settings (Auto-Sync & Interval)
• Sync only on actual changes (saves battery)
• 24h server offline warning
• HTTP only for local networks (RFC 1918 IPs)
• Removed auto-save notifications