🐛 Release v1.1.1 - Critical Bugfixes

 Server-Erreichbarkeits-Check vor jedem Sync
- Socket-Check mit 2s Timeout (DHCP/Routing-Init abwarten)
- Verhindert Fehler-Notifications in fremden WiFi-Netzen
- Verhindert Fehler bei Netzwerk-Initialisierung (WiFi-Connect)
- Stiller Abbruch wenn Server nicht erreichbar
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden

🔧 Notification-Verbesserungen
- Alte Notifications werden beim App-Start gelöscht
- Fehler-Notifications verschwinden automatisch nach 30s
- Bessere Batterie-Effizienz

📱 UI-Bugfixes
- Sync-Icon nur anzeigen wenn Sync konfiguriert ist
- Swipe-to-Delete: Kein Flackern mehr bei schnellem Löschen
- Scroll-to-Top nach Note Save (ListAdapter async fix)

📡 Sync-Architektur Dokumentation
- SYNC_ARCHITECTURE.md mit allen 4 Sync-Triggern
- DOCS.md + DOCS.en.md aktualisiert
- GitHub Actions: F-Droid Changelogs statt Commit-Messages

🎯 Testing: BUGFIX_SPURIOUS_SYNC_ERROR_NOTIFICATIONS.md
📦 Version: 1.1.1 (versionCode=3)
This commit is contained in:
inventory69
2025-12-26 12:18:51 +01:00
parent 7644f5bf76
commit 9b6bf04954
14 changed files with 381 additions and 32 deletions

View File

@@ -17,8 +17,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 2 // 🔥 F-Droid Release v1.1.0
versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section
versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug
versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -45,6 +45,9 @@ class MainActivity : AppCompatActivity() {
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
// Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
@@ -91,6 +94,9 @@ class MainActivity : AppCompatActivity() {
Logger.enableFileLogging(this)
}
// 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()
@@ -117,7 +123,7 @@ class MainActivity : AppCompatActivity() {
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes
// Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
@@ -142,10 +148,21 @@ class MainActivity : AppCompatActivity() {
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@MainActivity)
// ⭐ 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")
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
@@ -236,6 +253,9 @@ class MainActivity : AppCompatActivity() {
val note = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Remove from list immediately for visual feedback
notesCopy.removeAt(position)
adapter.submitList(notesCopy)
@@ -246,13 +266,15 @@ class MainActivity : AppCompatActivity() {
"Notiz gelöscht",
Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") {
// UNDO: Restore note in list
// UNDO: Remove from pending deletions and restore
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 → Actually delete the note
storage.deleteNote(note.id)
pendingDeletions.remove(note.id)
loadNotes()
}
}
@@ -276,10 +298,21 @@ class MainActivity : AppCompatActivity() {
private fun loadNotes() {
val notes = storage.loadAllNotes()
adapter.submitList(notes)
// 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 (notes.isEmpty()) {
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE
} else {
android.view.View.GONE
@@ -305,8 +338,21 @@ class MainActivity : AppCompatActivity() {
try {
showToast("Starte Synchronisation...")
// Start sync
// Create sync service
val syncService = WebDavSyncService(this@MainActivity)
// ⭐ 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")
showToast("Server nicht erreichbar")
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -11,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate
@@ -39,14 +41,25 @@ class NotesAdapter(
textViewContent.text = note.content.truncate(100)
textViewTimestamp.text = note.updatedAt.toReadableTime()
// Sync status icon
val syncIcon = when (note.syncStatus) {
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
// Sync Icon nur zeigen wenn Sync konfiguriert ist
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val isSyncConfigured = !serverUrl.isNullOrEmpty()
if (isSyncConfigured) {
// Sync status icon
val syncIcon = when (note.syncStatus) {
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
}
imageViewSyncStatus.setImageResource(syncIcon)
imageViewSyncStatus.visibility = View.VISIBLE
} else {
// Sync nicht konfiguriert → Icon verstecken
imageViewSyncStatus.visibility = View.GONE
}
imageViewSyncStatus.setImageResource(syncIcon)
itemView.setOnClickListener {
onNoteClick(note)

View File

@@ -52,7 +52,28 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Before syncNotes() call")
Logger.d(TAG, "📍 Step 2: Checking server reachability (Pre-Check)")
}
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
// Verhindert Fehler-Notifications in fremden WiFi-Netzen
// Wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
if (!syncService.isServerReachable()) {
Logger.d(TAG, "⏭️ Server not reachable - skipping sync (no error)")
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")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
Logger.d(TAG, "═══════════════════════════════════════")
}
// Success zurückgeben (kein Fehler, Server ist halt nicht erreichbar)
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
Logger.d(TAG, " SyncService: $syncService")
}
@@ -73,13 +94,13 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Processing result")
Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
}
if (result.isSuccess) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Success path")
Logger.d(TAG, "📍 Step 5: Success path")
}
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
@@ -109,7 +130,7 @@ class SyncWorker(
Result.success()
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Failure path")
Logger.d(TAG, "📍 Step 5: Failure path")
}
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError(

View File

@@ -20,6 +20,7 @@ import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Proxy
import java.net.Socket
import java.net.URL
import javax.net.SocketFactory
class WebDavSyncService(private val context: Context) {
@@ -188,6 +189,40 @@ class WebDavSyncService(private val context: Context) {
return prefs.getString(Constants.KEY_SERVER_URL, null)
}
/**
* 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) {
return@withContext try {
val serverUrl = getServerUrl()
if (serverUrl == null) {
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 2s Timeout
// Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway)
val socket = Socket()
socket.connect(InetSocketAddress(host, port), 2000)
socket.close()
Logger.d(TAG, "✅ Server is reachable")
true
} catch (e: Exception) {
Logger.d(TAG, "❌ Server not reachable: ${e.message}")
false
}
}
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getSardine() ?: return@withContext SyncResult(

View File

@@ -6,12 +6,15 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dev.dettmer.simplenotes.MainActivity
object NotificationHelper {
private const val TAG = "NotificationHelper"
private const val CHANNEL_ID = "notes_sync_channel"
private const val CHANNEL_NAME = "Notizen Synchronisierung"
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
@@ -38,6 +41,17 @@ object NotificationHelper {
}
}
/**
* Löscht alle Sync-Notifications
* Sollte beim App-Start aufgerufen werden um alte Notifications zu entfernen
*/
fun clearSyncNotifications(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.cancel(SYNC_NOTIFICATION_ID)
Logger.d(TAG, "🗑️ Cleared old sync notifications")
}
/**
* Zeigt Erfolgs-Notification nach Sync
*/
@@ -240,6 +254,7 @@ object NotificationHelper {
/**
* Zeigt Fehler-Notification
* Auto-Cancel nach 30 Sekunden
*/
fun showSyncError(context: Context, message: String) {
// PendingIntent für App-Öffnung
@@ -266,5 +281,11 @@ object NotificationHelper {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.notify(SYNC_NOTIFICATION_ID, notification)
// ⭐ NEU: Auto-Cancel nach 30 Sekunden
Handler(Looper.getMainLooper()).postDelayed({
manager.cancel(SYNC_NOTIFICATION_ID)
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
}, 30_000)
}
}