5 Commits

Author SHA1 Message Date
inventory69
cf1142afa2 [skip ci] fix: disable APK splits for F-Droid build
- F-Droid expects single universal APK, not multiple architecture-specific APKs
- Remove splits.abi configuration to fix build error in F-Droid CI
- Pipeline failed with: 'More than one resulting apks found' (armeabi-v7a, arm64-v8a, universal)
- Resolves F-Droid MR #31695 build failure
2026-01-09 13:19:47 +01:00
inventory69
359325bf64 [skip ci] chore: update author information in metadata 2026-01-09 11:33:18 +01:00
inventory69
c7d0f899e7 [skip ci] feat: new app icon with monochrome support & updated descriptions
🎨 New App Icon:
- Fresh adaptive icon design with warm background (#f9e9c8)
- Monochrome icon support for Android 13+ themed icons
- PNG format replacing WebP for better compatibility
- All densities: mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi

📝 Updated Descriptions (EN/DE):
- Added Multi-Device Sync feature
- Added Markdown export for Obsidian/desktop editors
- Added deletion tracking (zombie notes prevention)
- Added E-Tag caching (20x faster checks)
- Added optimized performance (~2-3s sync time)
- Added live sync status indicator
- Added Server-Restore modes (Merge/Replace/Overwrite)

📦 F-Droid Metadata:
- Updated build entries for v1.2.1, v1.2.2, v1.3.0, v1.3.1
- CurrentVersion now 1.3.1 (versionCode 9)
- Prepared for F-Droid merge request submission
2026-01-09 10:55:39 +01:00
inventory69
5121a7b2b8 Fix F-Droid changelogs: Remove emojis and bullet points [skip ci] 2026-01-08 23:18:29 +01:00
inventory69
04664c8920 v1.3.1 - Multi-Device Sync Fix + Performance + Restore Bug Fix
🔧 Fixed:
- Multi-device JSON sync now works (thanks Thomas!)
- Restore from Server skipped files (timestamp bug)
- No duplicate downloads
- First MD sync after export now fast

 Performance:
- JSON sync: 12-14s → 2-3s
- Hybrid timestamp + E-Tag optimization
- Matches Markdown sync speed

 New:
- Sync status UI in MainActivity
- Content-based MD import
- Debug logging improvements
- SyncStateManager for sync coordination

🔧 Technical:
- Clear lastSyncTimestamp on restore
- Clear E-Tag caches on restore
- E-Tag refresh after upload
- Fixed timestamp update after MD export
2026-01-08 23:09:59 +01:00
62 changed files with 1338 additions and 211 deletions

View File

@@ -6,6 +6,93 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [1.3.1] - 2026-01-08
### Fixed
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
- Funktioniert auch ohne aktiviertes Markdown
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
### Performance Improvements
- **⚡ JSON Sync Performance-Parität**
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
- **⚡ Session-Caching für WebDAV**
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
- **📝 Content-basierte Markdown-Erkennung**
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
### Added
- **🔄 Sync-Status-Anzeige (UI)**
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
### Fixed
- **🔧 Sync-Mutex verhindert doppelte Syncs**
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
- Concurrent Sync-Requests werden korrekt blockiert
- **🐛 Lint-Fehler behoben**
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
### Added
- **🔍 detekt Code-Analyse**
- Statische Code-Analyse mit detekt 1.23.4 integriert
- Pragmatische Konfiguration für Sync-intensive Codebasis
- 91 Issues identifiziert (als Baseline für v1.4.0)
- **🏗️ Debug Build mit separatem Package**
- Debug-APK kann parallel zur Release-Version installiert werden
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
- **📊 Debug-Logging UI**
- Neuer "Debug Log" Button in Einstellungen → Erweitert
- Zeigt letzte Sync-Logs mit Zeitstempeln
- Export-Funktion für Fehlerberichte
### Technical
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
- `ensureNotesDirectoryExists()`: Cached Directory-Check
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
---
## [1.3.0] - 2026-01-07 ## [1.3.0] - 2026-01-07
### Added ### Added

61
android/.editorconfig Normal file
View File

@@ -0,0 +1,61 @@
# ⚡ v1.3.1: EditorConfig for ktlint
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint rules
ktlint_code_style = android_studio
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_package-name = enabled
ktlint_standard_filename = enabled
ktlint_standard_class-naming = enabled
ktlint_standard_function-naming = enabled
ktlint_standard_property-naming = enabled
ktlint_standard_backing-property-naming = enabled
ktlint_standard_enum-entry-name-case = enabled
ktlint_standard_multiline-if-else = enabled
ktlint_standard_no-empty-class-body = enabled
ktlint_standard_no-empty-first-line-in-class-body = enabled
ktlint_standard_blank-line-before-declaration = enabled
ktlint_standard_context-receiver-wrapping = enabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = enabled
ktlint_standard_function-type-modifier-spacing = enabled
ktlint_standard_kdoc-wrapping = enabled
ktlint_standard_modifier-list-spacing = enabled
ktlint_standard_no-blank-line-in-list = enabled
ktlint_standard_no-consecutive-comments = disabled
ktlint_standard_no-single-line-block-comment = enabled
ktlint_standard_parameter-list-spacing = enabled
ktlint_standard_parameter-list-wrapping = enabled
ktlint_standard_property-wrapping = enabled
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
ktlint_standard_statement-wrapping = enabled
ktlint_standard_string-template-indent = disabled
ktlint_standard_try-catch-finally-spacing = enabled
ktlint_standard_type-argument-list-spacing = enabled
ktlint_standard_type-parameter-list-spacing = enabled
ktlint_standard_value-argument-comment = enabled
ktlint_standard_value-parameter-comment = enabled
[*.md]
trim_trailing_whitespace = false
[*.{xml,json}]
indent_size = 2
[*.yml]
indent_size = 2
[Makefile]
indent_style = tab

View File

@@ -1,6 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
} }
import java.util.Properties import java.util.Properties
@@ -17,8 +20,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 8 // 🚀 v1.3.0: Multi-Device Sync with deletion tracking versionCode = 9 // 🚀 v1.3.1: Sync-Performance & Debug-Logging
versionName = "1.3.0" // 🚀 v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import versionName = "1.3.1" // 🚀 v1.3.1: Skip unchanged MD-Files, Sync-Mutex, Debug-Logging UI
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -32,23 +35,15 @@ android {
includeInBundle = false // Also disable for AAB (Google Play) includeInBundle = false // Also disable for AAB (Google Play)
} }
// Enable multiple APKs per ABI for smaller downloads
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true // Also generate universal APK
}
}
// Product Flavors for F-Droid and standard builds // Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution" flavorDimensions += "distribution"
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "distribution" dimension = "distribution"
// F-Droid builds have no proprietary dependencies // F-Droid builds have no proprietary dependencies
// All dependencies in this project are already FOSS-compatible // All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK
} }
create("standard") { create("standard") {
@@ -75,6 +70,16 @@ android {
} }
buildTypes { buildTypes {
debug {
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -144,3 +149,27 @@ fun getBuildDate(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return dateFormat.format(Date()) return dateFormat.format(Date())
} }
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
// ktlint {
// android = true
// outputToConsole = true
// ignoreFailures = true
// enableExperimentalRules = false
// filter {
// exclude("**/generated/**")
// exclude("**/build/**")
// }
// }
// ⚡ v1.3.1: detekt-Konfiguration
detekt {
buildUponDefaultConfig = true
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
}

View File

@@ -39,8 +39,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -50,9 +53,16 @@ class MainActivity : AppCompatActivity() {
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout 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 lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } 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 // Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>() private val pendingDeletions = mutableSetOf<String>()
@@ -97,9 +107,10 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// File Logging aktivieren wenn eingestellt // Logger initialisieren und File-Logging aktivieren wenn eingestellt
if (prefs.getBoolean("file_logging_enabled", false)) { Logger.init(this)
Logger.enableFileLogging(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
} }
// Alte Sync-Notifications beim App-Start löschen // Alte Sync-Notifications beim App-Start löschen
@@ -116,6 +127,65 @@ class MainActivity : AppCompatActivity() {
setupFab() setupFab()
loadNotes() 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(1500)
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(3000)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.IDLE -> {
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
}
}
}
/**
* 🔄 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() { override fun onResume() {
@@ -151,6 +221,12 @@ class MainActivity : AppCompatActivity() {
return return
} }
// 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)") Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp // Update last sync timestamp
@@ -163,6 +239,7 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch return@launch
} }
@@ -173,6 +250,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch return@launch
} }
@@ -184,6 +262,7 @@ class MainActivity : AppCompatActivity() {
// Feedback abhängig von Source // Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast // onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen") showToast("✅ Gesynct: ${result.syncedCount} Notizen")
@@ -191,14 +270,17 @@ class MainActivity : AppCompatActivity() {
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}") Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} }
@@ -235,6 +317,10 @@ class MainActivity : AppCompatActivity() {
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout) swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
} }
private fun setupToolbar() { private fun setupToolbar() {
@@ -262,6 +348,12 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.setOnRefreshListener { swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync") 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 { lifecycleScope.launch {
try { try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
@@ -269,7 +361,7 @@ class MainActivity : AppCompatActivity() {
if (serverUrl.isNullOrEmpty()) { if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert") showToast("⚠️ Server noch nicht konfiguriert")
swipeRefreshLayout.isRefreshing = false SyncStateManager.reset()
return@launch return@launch
} }
@@ -278,15 +370,13 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
showToast("Bereits synchronisiert") SyncStateManager.markCompleted("Bereits synchronisiert")
swipeRefreshLayout.isRefreshing = false
return@launch return@launch
} }
// Check if server is reachable // Check if server is reachable
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") SyncStateManager.markError("Server nicht erreichbar")
swipeRefreshLayout.isRefreshing = false
return@launch return@launch
} }
@@ -294,16 +384,14 @@ class MainActivity : AppCompatActivity() {
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
showToast("${result.syncedCount} Notizen synchronisiert") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() loadNotes()
} else { } else {
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}") SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e) Logger.e(TAG, "Pull-to-Refresh sync failed", e)
showToast("❌ Fehler: ${e.message}") SyncStateManager.markError(e.message)
} finally {
swipeRefreshLayout.isRefreshing = false
} }
} }
} }
@@ -493,6 +581,11 @@ class MainActivity : AppCompatActivity() {
} }
private fun triggerManualSync() { private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// Create sync service // Create sync service
@@ -501,12 +594,10 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
showToast("Bereits synchronisiert") SyncStateManager.markCompleted("Bereits synchronisiert")
return@launch return@launch
} }
showToast("Starte Synchronisation...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable() syncService.isServerReachable()
@@ -514,7 +605,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
showToast("Server nicht erreichbar") SyncStateManager.markError("Server nicht erreichbar")
return@launch return@launch
} }
@@ -525,20 +616,21 @@ class MainActivity : AppCompatActivity() {
// Show result // Show result
if (result.isSuccess) { if (result.isSuccess) {
showToast("Sync erfolgreich: ${result.syncedCount} Notizen") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() // Reload notes loadNotes() // Reload notes
} else { } else {
showToast("Sync Fehler: ${result.errorMessage}") SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Sync Fehler: ${e.message}") SyncStateManager.markError(e.message)
} }
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu) menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true return true
} }

View File

@@ -34,6 +34,7 @@ import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.utils.UrlValidator import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
@@ -83,6 +84,11 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var cardDeveloperProfile: MaterialCardView private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: 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 // Backup Manager
private val backupManager by lazy { BackupManager(this) } private val backupManager by lazy { BackupManager(this) }
@@ -124,6 +130,7 @@ class SettingsActivity : AppCompatActivity() {
setupListeners() setupListeners()
setupSyncIntervalPicker() setupSyncIntervalPicker()
setupAboutSection() setupAboutSection()
setupDebugSection()
} }
private fun findViews() { private fun findViews() {
@@ -156,6 +163,11 @@ class SettingsActivity : AppCompatActivity() {
cardGitHubRepo = findViewById(R.id.cardGitHubRepo) cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile) cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
cardLicense = findViewById(R.id.cardLicense) 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() { private fun loadSettings() {
@@ -386,6 +398,109 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
/**
* Setup Debug section with file logging toggle and export functionality
*/
private fun setupDebugSection() {
// 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")
} else {
showToast("📝 Datei-Logging deaktiviert")
}
}
// Export logs button
buttonExportLogs.setOnClickListener {
exportAndShareLogs()
}
// Clear logs button
buttonClearLogs.setOnClickListener {
showClearLogsConfirmation()
}
}
/**
* Export logs and share via system share sheet
*/
private fun exportAndShareLogs() {
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
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Clear all log files
*/
private fun clearLogs() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
} else {
showToast("📭 Keine Logs zum Löschen")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
}
}
/** /**
* Opens URL in browser * Opens URL in browser
*/ */
@@ -467,6 +582,14 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun syncNow() { 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 { lifecycleScope.launch {
try { try {
val syncService = WebDavSyncService(this@SettingsActivity) val syncService = WebDavSyncService(this@SettingsActivity)
@@ -474,14 +597,16 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert") showToast("✅ Bereits synchronisiert")
SyncStateManager.markCompleted()
return@launch return@launch
} }
showToast("Synchronisiere...") showToast("🔄 Synchronisiere...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") showToast("⚠️ Server nicht erreichbar")
SyncStateManager.markError("Server nicht erreichbar")
checkServerStatus() // Server-Status aktualisieren checkServerStatus() // Server-Status aktualisieren
return@launch return@launch
} }
@@ -490,18 +615,24 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) { if (result.isSuccess) {
if (result.hasConflicts) { if (result.hasConflicts) {
showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!") showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
} else { } else {
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert") showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
} }
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
} else { } else {
showToast("Sync fehlgeschlagen: ${result.errorMessage}") showToast("Sync fehlgeschlagen: ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
checkServerStatus() // ✅ Auch bei Fehler aktualisieren checkServerStatus() // ✅ Auch bei Fehler aktualisieren
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Fehler: ${e.message}") showToast("Fehler: ${e.message}")
SyncStateManager.markError(e.message)
checkServerStatus() // ✅ Auch bei Exception aktualisieren checkServerStatus() // ✅ Auch bei Exception aktualisieren
} finally {
// Re-enable button
buttonSyncNow.isEnabled = true
} }
} }
} }
@@ -824,20 +955,20 @@ class SettingsActivity : AppCompatActivity() {
// Radio Buttons erstellen // Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply { val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten" text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
id = 0 id = android.view.View.generateViewId()
isChecked = true isChecked = true
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioReplace = android.widget.RadioButton(this).apply { val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren" text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
id = 1 id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioOverwrite = android.widget.RadioButton(this).apply { val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten" text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
id = 2 id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
@@ -876,8 +1007,8 @@ class SettingsActivity : AppCompatActivity() {
.setView(mainLayout) .setView(mainLayout)
.setPositiveButton("Wiederherstellen") { _, _ -> .setPositiveButton("Wiederherstellen") { _, _ ->
val selectedMode = when (radioGroup.checkedRadioButtonId) { val selectedMode = when (radioGroup.checkedRadioButtonId) {
1 -> RestoreMode.REPLACE radioReplace.id -> RestoreMode.REPLACE
2 -> RestoreMode.OVERWRITE_DUPLICATES radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
else -> RestoreMode.MERGE else -> RestoreMode.MERGE
} }

View File

@@ -0,0 +1,129 @@
package dev.dettmer.simplenotes.sync
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import dev.dettmer.simplenotes.utils.Logger
/**
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
*
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
*/
object SyncStateManager {
private const val TAG = "SyncStateManager"
/**
* Mögliche Sync-Zustände
*/
enum class SyncState {
IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
}
/**
* Detaillierte Sync-Informationen für UI
*/
data class SyncStatus(
val state: SyncState = SyncState.IDLE,
val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val timestamp: Long = System.currentTimeMillis()
)
// Private mutable LiveData
private val _syncStatus = MutableLiveData(SyncStatus())
// Public immutable LiveData für Observer
val syncStatus: LiveData<SyncStatus> = _syncStatus
// Lock für Thread-Sicherheit
private val lock = Any()
/**
* Prüft ob gerade ein Sync läuft
*/
val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING
/**
* Versucht einen Sync zu starten.
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/
fun tryStartSync(source: String): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false
}
Logger.d(TAG, "🔄 Starting sync from: $source")
_syncStatus.postValue(
SyncStatus(
state = SyncState.SYNCING,
message = "Synchronisiere...",
source = source
)
)
return true
}
}
/**
* Markiert Sync als erfolgreich abgeschlossen
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
)
)
}
}
/**
* Markiert Sync als fehlgeschlagen
*/
fun markError(errorMessage: String?) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
_syncStatus.postValue(
SyncStatus(
state = SyncState.ERROR,
message = errorMessage,
source = currentSource
)
)
}
}
/**
* Setzt Status zurück auf IDLE
*/
fun reset() {
synchronized(lock) {
_syncStatus.postValue(SyncStatus())
}
}
/**
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
*/
fun updateMessage(message: String) {
synchronized(lock) {
val current = _syncStatus.value ?: return
if (current.state == SyncState.SYNCING) {
_syncStatus.postValue(current.copy(message = message))
}
}
}
}

View File

@@ -13,6 +13,7 @@ import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.net.Inet4Address import java.net.Inet4Address
@@ -37,11 +38,20 @@ class WebDavSyncService(private val context: Context) {
companion object { companion object {
private const val TAG = "WebDavSyncService" private const val TAG = "WebDavSyncService"
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
private val syncMutex = Mutex()
} }
private val storage: NotesStorage private val storage: NotesStorage
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
private var markdownDirEnsured = false // Cache für Ordner-Existenz 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: Sardine? = null
private var sessionWifiAddress: InetAddress? = null
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
init { init {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@@ -73,10 +83,25 @@ class WebDavSyncService(private val context: Context) {
} }
} }
/**
* ⚡ v1.3.1: Gecachte WiFi-Adresse zurückgeben oder berechnen
*/
private fun getOrCacheWiFiAddress(): InetAddress? {
// Return cached if already checked this session
if (sessionWifiAddressChecked) {
return sessionWifiAddress
}
// Calculate and cache
sessionWifiAddress = getWiFiInetAddressInternal()
sessionWifiAddressChecked = true
return sessionWifiAddress
}
/** /**
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen) * Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
*/ */
private fun getWiFiInetAddress(): InetAddress? { private fun getWiFiInetAddressInternal(): InetAddress? {
try { try {
Logger.d(TAG, "🔍 getWiFiInetAddress() called") Logger.d(TAG, "🔍 getWiFiInetAddress() called")
@@ -171,15 +196,35 @@ class WebDavSyncService(private val context: Context) {
} }
} }
private fun getSardine(): Sardine? { /**
* ⚡ 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 {
Logger.d(TAG, "⚡ Reusing cached Sardine client")
return it
}
// Create new client
val sardine = createSardineClient()
sessionSardine = sardine
return sardine
}
/**
* Erstellt einen neuen Sardine-Client (intern)
*/
private fun createSardineClient(): Sardine? {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding") Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
Logger.d(TAG, " Context: ${context.javaClass.simpleName}") Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
// Versuche WiFi-IP zu finden // ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
val wifiAddress = getWiFiInetAddress() val wifiAddress = getOrCacheWiFiAddress()
val okHttpClient = if (wifiAddress != null) { val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory") Logger.d(TAG, "✅ Using WiFi-bound socket factory")
@@ -196,6 +241,18 @@ class WebDavSyncService(private val context: Context) {
} }
} }
/**
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
*/
private fun clearSessionCache() {
sessionSardine = null
sessionWifiAddress = null
sessionWifiAddressChecked = false
notesDirEnsured = false
markdownDirEnsured = false
Logger.d(TAG, "🧹 Session caches cleared")
}
private fun getServerUrl(): String? { private fun getServerUrl(): String? {
return prefs.getString(Constants.KEY_SERVER_URL, null) return prefs.getString(Constants.KEY_SERVER_URL, null)
} }
@@ -266,6 +323,31 @@ class WebDavSyncService(private val context: Context) {
} }
} }
/**
* ⚡ v1.3.1: Stellt sicher dass notes/ Ordner existiert (mit Cache)
*
* Spart ~500ms pro Sync durch Caching
*/
private fun ensureNotesDirectoryExists(sardine: Sardine, notesUrl: String) {
if (notesDirEnsured) {
Logger.d(TAG, "⚡ notes/ directory already verified (cached)")
return
}
try {
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
if (!sardine.exists(notesUrl)) {
Logger.d(TAG, "📁 Creating notes/ directory...")
sardine.createDirectory(notesUrl)
}
Logger.d(TAG, " ✅ notes/ directory ready")
notesDirEnsured = true
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
throw e
}
}
/** /**
* Checks if server has changes using E-Tag caching * Checks if server has changes using E-Tag caching
* *
@@ -298,47 +380,13 @@ class WebDavSyncService(private val context: Context) {
// ====== JSON FILES CHECK (/notes/) ====== // ====== JSON FILES CHECK (/notes/) ======
// Optimierung 1: E-Tag Check (fastest - ~100ms) // ⚡ v1.3.1: File-level E-Tag check in downloadRemoteNotes() is optimal!
val cachedETag = prefs.getString("notes_collection_etag", null) // Collection E-Tag doesn't work (server-dependent, doesn't track file changes)
var jsonHasChanges = false // → Always proceed to download phase where file-level E-Tags provide fast skips
if (cachedETag != null) { // For hasUnsyncedChanges(): Conservative approach - assume changes may exist
try { // Actual file-level E-Tag checks in downloadRemoteNotes() will skip unchanged files (0ms each)
val resources = sardine.list(notesUrl, 0) // Depth 0 = only collection itself var hasJsonChanges = true // Assume yes, let file E-Tags optimize
val currentETag = resources.firstOrNull()?.contentLength?.toString() ?: ""
if (currentETag == cachedETag) {
val elapsed = System.currentTimeMillis() - startTime
Logger.d(TAG, "⚡ E-Tag match - no JSON changes (${elapsed}ms)")
// Don't return yet - check Markdown too!
} else {
Logger.d(TAG, "🔄 E-Tag changed - JSON files have updates")
return true // Early return if JSON changed
}
} catch (e: Exception) {
Logger.w(TAG, "E-Tag check failed: ${e.message}, falling back to timestamp check")
jsonHasChanges = true
}
} else {
jsonHasChanges = true
}
// Optimierung 2: Smart Timestamp Check for JSON (medium - ~300ms)
if (jsonHasChanges || cachedETag == null) {
val resources = sardine.list(notesUrl, 1) // Depth 1 = collection + children
val jsonHasNewer = resources.any { resource ->
!resource.isDirectory &&
resource.name.endsWith(".json") &&
resource.modified?.time?.let { it > lastSyncTime } ?: false
}
if (jsonHasNewer) {
val elapsed = System.currentTimeMillis() - startTime
Logger.d(TAG, "🔍 JSON check: hasNewer=true (${resources.size} resources, ${elapsed}ms)")
return true
}
}
// ====== MARKDOWN FILES CHECK (/notes-md/) ====== // ====== MARKDOWN FILES CHECK (/notes-md/) ======
// IMPORTANT: E-Tag for collections does NOT work for content changes! // IMPORTANT: E-Tag for collections does NOT work for content changes!
@@ -382,7 +430,15 @@ class WebDavSyncService(private val context: Context) {
} }
val elapsed = System.currentTimeMillis() - startTime val elapsed = System.currentTimeMillis() - startTime
Logger.d(TAG, "✅ No changes detected (JSON + Markdown checked, ${elapsed}ms)")
// 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 return false
} catch (e: Exception) { } catch (e: Exception) {
@@ -429,7 +485,7 @@ class WebDavSyncService(private val context: Context) {
} }
// Perform intelligent server check // Perform intelligent server check
val sardine = getSardine() val sardine = getOrCreateSardine()
val serverUrl = getServerUrl() val serverUrl = getServerUrl()
if (sardine == null || serverUrl == null) { if (sardine == null || serverUrl == null) {
@@ -484,7 +540,7 @@ class WebDavSyncService(private val context: Context) {
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) { suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getSardine() ?: return@withContext SyncResult( val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert" errorMessage = "Server-Zugangsdaten nicht konfiguriert"
) )
@@ -529,18 +585,29 @@ class WebDavSyncService(private val context: Context) {
} }
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) { suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
Logger.d(TAG, "═══════════════════════════════════════") // 🔒 v1.3.1: Verhindere parallele Syncs
Logger.d(TAG, "🔄 syncNotes() ENTRY") if (!syncMutex.tryLock()) {
Logger.d(TAG, "Context: ${context.javaClass.simpleName}") Logger.d(TAG, "⏭️ Sync already in progress - skipping")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}") return@withContext SyncResult(
isSuccess = true,
syncedCount = 0,
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 { return@withContext try {
Logger.d(TAG, "📍 Step 1: Getting Sardine client") Logger.d(TAG, "📍 Step 1: Getting Sardine client")
val sardine = try { val sardine = try {
getSardine() getOrCreateSardine()
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in getSardine()!", e) Logger.e(TAG, "💥 CRASH in getOrCreateSardine()!", e)
e.printStackTrace() e.printStackTrace()
throw e throw e
} }
@@ -571,20 +638,9 @@ class WebDavSyncService(private val context: Context) {
var conflictCount = 0 var conflictCount = 0
Logger.d(TAG, "📍 Step 3: Checking server directory") Logger.d(TAG, "📍 Step 3: Checking server directory")
// Ensure notes/ directory exists // ⚡ v1.3.1: Verwende gecachte Directory-Checks
val notesUrl = getNotesUrl(serverUrl) val notesUrl = getNotesUrl(serverUrl)
try { ensureNotesDirectoryExists(sardine, notesUrl)
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
if (!sardine.exists(notesUrl)) {
Logger.d(TAG, "📁 Creating notes/ directory...")
sardine.createDirectory(notesUrl)
}
Logger.d(TAG, " ✅ notes/ directory ready")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
e.printStackTrace()
throw e
}
// Ensure notes-md/ directory exists (for Markdown export) // Ensure notes-md/ directory exists (for Markdown export)
ensureMarkdownDirectoryExists(sardine, serverUrl) ensureMarkdownDirectoryExists(sardine, serverUrl)
@@ -697,6 +753,12 @@ class WebDavSyncService(private val context: Context) {
} }
) )
} }
} finally {
// ⚡ v1.3.1: Session-Caches leeren
clearSessionCache()
// 🔒 v1.3.1: Sync-Mutex freigeben
syncMutex.unlock()
}
} }
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
@@ -712,13 +774,33 @@ class WebDavSyncService(private val context: Context) {
val noteUrl = "$notesUrl${note.id}.json" val noteUrl = "$notesUrl${note.id}.json"
val jsonBytes = note.toJson().toByteArray() val jsonBytes = note.toJson().toByteArray()
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
sardine.put(noteUrl, jsonBytes, "application/json") sardine.put(noteUrl, jsonBytes, "application/json")
Logger.d(TAG, " ✅ Upload successful")
// Update sync status // Update sync status
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED) val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
uploadedCount++ uploadedCount++
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
// Get new E-Tag from server via PROPFIND
try {
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
val newETag = uploadedResource?.etag
if (newETag != null) {
prefs.edit().putString("etag_json_${note.id}", newETag).apply()
Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(8)}")
} else {
// Fallback: invalidate if server doesn't provide E-Tag
prefs.edit().remove("etag_json_${note.id}").apply()
Logger.d(TAG, " ⚠️ No E-Tag from server, invalidated cache")
}
} catch (e: Exception) {
Logger.w(TAG, " ⚠️ Failed to refresh E-Tag: ${e.message}")
prefs.edit().remove("etag_json_${note.id}").apply()
}
// 2. Markdown-Export (NEU in v1.2.0) // 2. Markdown-Export (NEU in v1.2.0)
// Läuft NACH erfolgreichem JSON-Upload // Läuft NACH erfolgreichem JSON-Upload
if (markdownExportEnabled) { if (markdownExportEnabled) {
@@ -800,8 +882,8 @@ class WebDavSyncService(private val context: Context) {
): Int = withContext(Dispatchers.IO) { ): Int = withContext(Dispatchers.IO) {
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...") Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
// Erstelle Sardine-Client mit gegebenen Credentials // ⚡ v1.3.1: Use cached WiFi address
val wifiAddress = getWiFiInetAddress() val wifiAddress = getOrCacheWiFiAddress()
val okHttpClient = if (wifiAddress != null) { val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory") Logger.d(TAG, "✅ Using WiFi-bound socket factory")
@@ -854,6 +936,15 @@ class WebDavSyncService(private val context: Context) {
} }
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes") 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) {
val timestamp = System.currentTimeMillis()
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)")
}
return@withContext exportedCount return@withContext exportedCount
} }
@@ -886,17 +977,62 @@ class WebDavSyncService(private val context: Context) {
val notesUrl = getNotesUrl(serverUrl) val notesUrl = getNotesUrl(serverUrl)
Logger.d(TAG, "🔍 Phase 1: Checking /notes/ at: $notesUrl") 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)) { if (sardine.exists(notesUrl)) {
Logger.d(TAG, " ✅ /notes/ exists, scanning...") Logger.d(TAG, " ✅ /notes/ exists, scanning...")
val resources = sardine.list(notesUrl) val resources = sardine.list(notesUrl)
val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") }
Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server")
for (resource in resources) { for (resource in jsonFiles) {
if (resource.isDirectory || !resource.name.endsWith(".json")) {
val noteId = resource.name.removeSuffix(".json")
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
// ⚡ v1.3.1: HYBRID PERFORMANCE - Timestamp + E-Tag (like Markdown!)
val serverETag = resource.etag
val cachedETag = prefs.getString("etag_json_$noteId", null)
val serverModified = resource.modified?.time ?: 0L
// 🐛 DEBUG: Log every file check to diagnose performance
val serverETagPreview = serverETag?.take(8) ?: "null"
val cachedETagPreview = cachedETag?.take(8) ?: "null"
Logger.d(TAG, " 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview modified=$serverModified lastSync=$lastSyncTime")
// PRIMARY: Timestamp check (works on first sync!)
// Same logic as Markdown sync - skip if not modified since last sync
if (!forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) {
skippedUnchanged++
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
processedIds.add(noteId)
continue continue
} }
// 🔧 Fix: Build full URL instead of using href directly // SECONDARY: E-Tag check (for performance after first sync)
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name // Catches cases where file was re-uploaded with same content
if (!forceOverwrite && serverETag != null && serverETag == cachedETag) {
skippedUnchanged++
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
processedIds.add(noteId)
continue
}
// 🐛 DEBUG: Log download reason
val downloadReason = when {
lastSyncTime == 0L -> "First sync ever"
serverModified > lastSyncTime && serverETag == null -> "Modified + no server E-Tag"
serverModified > lastSyncTime && cachedETag == null -> "Modified + no cached E-Tag"
serverModified > lastSyncTime -> "Modified + E-Tag changed"
serverETag == null -> "No server E-Tag"
cachedETag == null -> "No cached E-Tag"
else -> "E-Tag changed"
}
Logger.d(TAG, " 📥 Downloading $noteId: $downloadReason")
// Download and process
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue val remoteNote = Note.fromJson(jsonContent) ?: continue
@@ -928,12 +1064,22 @@ class WebDavSyncService(private val context: Context) {
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ downloadedCount++
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}") Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync
if (serverETag != null) {
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
}
} }
forceOverwrite -> { forceOverwrite -> {
// OVERWRITE mode: Always replace regardless of timestamps // OVERWRITE mode: Always replace regardless of timestamps
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ downloadedCount++
Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync
if (serverETag != null) {
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
}
} }
localNote.updatedAt < remoteNote.updatedAt -> { localNote.updatedAt < remoteNote.updatedAt -> {
// Remote is newer // Remote is newer
@@ -946,11 +1092,16 @@ class WebDavSyncService(private val context: Context) {
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ downloadedCount++
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}") Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync
if (serverETag != null) {
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
}
} }
} }
} }
} }
Logger.d(TAG, " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted)") Logger.d(TAG, " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), $skippedUnchanged skipped (unchanged)")
} else { } else {
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
} }
@@ -1065,36 +1216,14 @@ class WebDavSyncService(private val context: Context) {
private fun saveLastSyncTimestamp() { private fun saveLastSyncTimestamp() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// v1.3.0: Save E-Tag only for JSON (Markdown uses timestamp check) // v1.3.1: Simplified - file-level E-Tags cached individually in downloadRemoteNotes()
try { // No need for collection E-Tag (doesn't work reliably across WebDAV servers)
val sardine = getSardine()
val serverUrl = getServerUrl()
if (sardine != null && serverUrl != null) {
val notesUrl = getNotesUrl(serverUrl)
// JSON E-Tag only
val notesResources = sardine.list(notesUrl, 0)
val notesETag = notesResources.firstOrNull()?.contentLength?.toString()
prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
.putString("notes_collection_etag", notesETag)
.apply()
Logger.d(TAG, "💾 Saved sync timestamp + JSON E-Tag")
return
}
} catch (e: Exception) {
Logger.w(TAG, "Failed to save E-Tag: ${e.message}")
}
// Fallback: Save timestamp only
prefs.edit() prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, now) .putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync .putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
.apply() .apply()
Logger.d(TAG, "💾 Saved sync timestamp (file E-Tags cached individually)")
} }
fun getLastSyncTimestamp(): Long { fun getLastSyncTimestamp(): Long {
@@ -1114,7 +1243,7 @@ class WebDavSyncService(private val context: Context) {
mode: dev.dettmer.simplenotes.backup.RestoreMode = dev.dettmer.simplenotes.backup.RestoreMode.REPLACE mode: dev.dettmer.simplenotes.backup.RestoreMode = dev.dettmer.simplenotes.backup.RestoreMode.REPLACE
): RestoreResult = withContext(Dispatchers.IO) { ): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getSardine() ?: return@withContext RestoreResult( val sardine = getOrCreateSardine() ?: return@withContext RestoreResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert", errorMessage = "Server-Zugangsdaten nicht konfiguriert",
restoredCount = 0 restoredCount = 0
@@ -1137,6 +1266,20 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "🗑️ Clearing deletion tracker (restore mode)") Logger.d(TAG, "🗑️ Clearing deletion tracker (restore mode)")
storage.clearDeletionTracker() 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 ->
editor.remove(key)
}
editor.apply()
Logger.d(TAG, "🔄 Cleared E-Tag caches - will re-download all files")
// Determine forceOverwrite flag // Determine forceOverwrite flag
val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES) val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES)
Logger.d(TAG, "forceOverwrite: $forceOverwrite") Logger.d(TAG, "forceOverwrite: $forceOverwrite")
@@ -1314,6 +1457,8 @@ class WebDavSyncService(private val context: Context) {
/** /**
* Auto-import Markdown files during regular sync (v1.3.0) * Auto-import Markdown files during regular sync (v1.3.0)
* Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled * Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled
*
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
*/ */
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int { private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
return try { return try {
@@ -1329,11 +1474,26 @@ class WebDavSyncService(private val context: Context) {
val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") } val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") }
var importedCount = 0 var importedCount = 0
var skippedCount = 0 // ⚡ v1.3.1: Zähle übersprungene Dateien
Logger.d(TAG, " 📂 Found ${mdResources.size} markdown files") 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) { for (resource in mdResources) {
try { 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) {
skippedCount++
Logger.d(TAG, " ⏭️ Skipping ${resource.name}: not modified since last sync")
continue
}
Logger.d(TAG, " 🔍 Processing: ${resource.name}, modified=${resource.modified}") Logger.d(TAG, " 🔍 Processing: ${resource.name}, modified=${resource.modified}")
// Build full URL // Build full URL
@@ -1354,11 +1514,22 @@ class WebDavSyncService(private val context: Context) {
val localNote = storage.loadNote(mdNote.id) val localNote = storage.loadNote(mdNote.id)
Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}") Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}")
// Use server file modification time for reliable change detection // ⚡ v1.3.1: Content-basierte Erkennung
val serverModifiedTime = resource.modified?.time ?: 0L // Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde!
Logger.d(TAG, " Comparison: serverModified=$serverModifiedTime, localUpdated=${localNote?.updatedAt ?: 0L}") // Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian)
Logger.d(TAG, " Comparison: mdUpdatedAt=${mdNote.updatedAt}, localUpdated=${localNote?.updatedAt ?: 0L}")
// Conflict resolution: Last-Write-Wins // Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
val contentChanged = localNote != null && (
mdNote.content != localNote.content ||
mdNote.title != localNote.title
)
if (contentChanged) {
Logger.d(TAG, " 📝 Content differs from local!")
}
// Conflict resolution: Content-First, dann Timestamp
when { when {
localNote == null -> { localNote == null -> {
// New note from desktop // New note from desktop
@@ -1366,57 +1537,41 @@ class WebDavSyncService(private val context: Context) {
importedCount++ importedCount++
Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}") Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}")
} }
serverModifiedTime > localNote.updatedAt -> { // ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich
// Server file is newer (based on modification time) localNote.syncStatus == SyncStatus.SYNCED && !contentChanged && localNote.updatedAt >= mdNote.updatedAt -> {
Logger.d(TAG, " Decision: Server is newer!") // Inhalt identisch UND Timestamps passen → Skip
skippedCount++
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: content identical (local=${localNote.updatedAt}, md=${mdNote.updatedAt})")
}
// ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren!
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
// Inhalt wurde extern geändert ohne YAML-Update → mit aktuellem Timestamp importieren
val newTimestamp = System.currentTimeMillis()
storage.saveNote(mdNote.copy(
updatedAt = newTimestamp,
syncStatus = SyncStatus.SYNCED
))
importedCount++
Logger.d(TAG, " ✅ Imported changed content (YAML timestamp outdated): ${mdNote.title}")
}
mdNote.updatedAt > localNote.updatedAt -> {
// Markdown has newer YAML timestamp
Logger.d(TAG, " Decision: Markdown has newer timestamp!")
if (localNote.syncStatus == SyncStatus.PENDING) { if (localNote.syncStatus == SyncStatus.PENDING) {
// Conflict: local has pending changes // Conflict: local has pending changes
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
Logger.w(TAG, " ⚠️ Conflict: Markdown vs local pending: ${mdNote.id}") Logger.w(TAG, " ⚠️ Conflict: Markdown vs local pending: ${mdNote.id}")
} else { } else {
// Content comparison to preserve timestamps on export-only updates // Import with the newer YAML timestamp
val contentChanged = mdNote.content != localNote.content || storage.saveNote(mdNote.copy(syncStatus = SyncStatus.SYNCED))
mdNote.title != localNote.title
// Detect if YAML timestamp wasn't updated despite content change
val yamlInconsistent = contentChanged && mdNote.updatedAt <= localNote.updatedAt
// Log inconsistencies for debugging
if (yamlInconsistent) {
Logger.w(TAG, " ⚠️ Inconsistency: ${mdNote.title}")
Logger.w(TAG, " Content changed but YAML timestamp not updated")
Logger.w(TAG, " YAML: ${mdNote.updatedAt}, Local: ${localNote.updatedAt}")
Logger.w(TAG, " Using current time as fallback")
}
// Determine final timestamp with auto-correction
val finalUpdatedAt: Long = when {
// No content change → preserve local timestamp (export-only)
!contentChanged -> localNote.updatedAt
// Content changed + YAML timestamp properly updated
!yamlInconsistent -> mdNote.updatedAt
// Content changed + YAML timestamp NOT updated → use current time
else -> System.currentTimeMillis()
}
storage.saveNote(mdNote.copy(
updatedAt = finalUpdatedAt,
syncStatus = SyncStatus.SYNCED
))
importedCount++ importedCount++
Logger.d(TAG, " ✅ Updated from Markdown (newer timestamp): ${mdNote.title}")
// Detailed logging
when {
!contentChanged -> Logger.d(TAG, " ✅ Re-synced (export-only, timestamp preserved): ${mdNote.title}")
yamlInconsistent -> Logger.d(TAG, " ✅ Updated (content changed, timestamp corrected): ${mdNote.title}")
else -> Logger.d(TAG, " ✅ Updated (content changed, YAML timestamp valid): ${mdNote.title}")
}
} }
} }
else -> { else -> {
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer (server=$serverModifiedTime, local=${localNote.updatedAt})") // Local has pending changes but MD is older - keep local
skippedCount++
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer or pending (local=${localNote.updatedAt}, md=${mdNote.updatedAt})")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -1425,7 +1580,8 @@ class WebDavSyncService(private val context: Context) {
} }
} }
Logger.d(TAG, " 📊 Markdown import complete: $importedCount notes") // ⚡ v1.3.1: Verbessertes Logging mit Skip-Count
Logger.d(TAG, " 📊 Markdown import complete: $importedCount imported, $skippedCount skipped (unchanged)")
importedCount importedCount
} catch (e: Exception) { } catch (e: Exception) {
@@ -1493,7 +1649,7 @@ class WebDavSyncService(private val context: Context) {
*/ */
suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) { suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getSardine() ?: return@withContext false val sardine = getOrCreateSardine() ?: return@withContext false
val serverUrl = getServerUrl() ?: return@withContext false val serverUrl = getServerUrl() ?: return@withContext false
var deletedJson = false var deletedJson = false
@@ -1563,7 +1719,7 @@ class WebDavSyncService(private val context: Context) {
*/ */
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) { suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden") val sardine = getOrCreateSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden")
val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert") val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert")
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""

View File

@@ -27,6 +27,9 @@ object Constants {
const val KEY_ALWAYS_CHECK_SERVER = "always_check_server" const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server" const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
// 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -17,9 +17,32 @@ object Logger {
private var fileLoggingEnabled = false private var fileLoggingEnabled = false
private var logFile: File? = null private var logFile: File? = null
private var appContext: Context? = null
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
private val maxLogEntries = 500 // Nur letzte 500 Einträge private val maxLogEntries = 500 // Nur letzte 500 Einträge
/**
* Setzt den File-Logging Status (für UI Toggle)
*/
fun setFileLoggingEnabled(enabled: Boolean) {
fileLoggingEnabled = enabled
if (!enabled) {
logFile = null
}
}
/**
* Gibt zurück, ob File-Logging aktiviert ist
*/
fun isFileLoggingEnabled(): Boolean = fileLoggingEnabled
/**
* Initialisiert den Logger mit App-Context
*/
fun init(context: Context) {
appContext = context.applicationContext
}
/** /**
* Aktiviert File-Logging für Debugging * Aktiviert File-Logging für Debugging
*/ */
@@ -50,11 +73,47 @@ object Logger {
*/ */
fun getLogFile(): File? = logFile fun getLogFile(): File? = logFile
/**
* Gibt Log-Datei mit Context zurück (für SettingsActivity)
*/
fun getLogFile(context: Context): File? {
if (logFile == null && fileLoggingEnabled) {
logFile = File(context.filesDir, "simplenotes_debug.log")
}
return logFile
}
/**
* Löscht die Log-Datei
*/
fun clearLogFile(context: Context): Boolean {
return try {
val file = File(context.filesDir, "simplenotes_debug.log")
if (file.exists()) {
file.delete()
logFile = null
true
} else {
false
}
} catch (e: Exception) {
Log.e("Logger", "Failed to clear log file", e)
false
}
}
/** /**
* Schreibt Log-Eintrag in Datei * Schreibt Log-Eintrag in Datei
*/ */
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) { private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
if (!fileLoggingEnabled || logFile == null) return if (!fileLoggingEnabled) return
// Lazy-init logFile mit appContext
if (logFile == null && appContext != null) {
logFile = File(appContext!!.filesDir, "simplenotes_debug.log")
}
if (logFile == null) return
try { try {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())

View File

@@ -22,6 +22,39 @@
app:title="@string/app_name" app:title="@string/app_name"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- 🔄 v1.3.1: Sync Status Banner -->
<LinearLayout
android:id="@+id/syncStatusBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:background="?attr/colorPrimaryContainer"
android:visibility="gone">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/syncProgressIndicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:indeterminate="true"
app:indicatorSize="24dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnPrimaryContainer" />
<TextView
android:id="@+id/syncStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:text="@string/sync_status_syncing"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnPrimaryContainer" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) --> <!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->

View File

@@ -749,6 +749,96 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Debug Section -->
<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="Debug &amp; Diagnose"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- File Logging Toggle -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📝 Datei-Logging"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Logs in Datei speichern"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFileLogging"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Export Logs Button -->
<Button
android:id="@+id/buttonExportLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Logs exportieren &amp; teilen"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginBottom="8dp" />
<!-- Clear Logs Button -->
<Button
android:id="@+id/buttonClearLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🗑️ Logs löschen"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -15,7 +15,7 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:src="@android:drawable/ic_menu_delete" android:src="@android:drawable/ic_menu_delete"
android:tint="?attr/colorError" app:tint="?attr/colorError"
android:contentDescription="@string/delete" /> android:contentDescription="@string/delete" />
<!-- Title --> <!-- Title -->

View File

@@ -64,7 +64,7 @@
android:layout_height="18dp" android:layout_height="18dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:src="@android:drawable/ic_popup_sync" android:src="@android:drawable/ic_popup_sync"
android:tint="?attr/colorPrimary" app:tint="?attr/colorPrimary"
android:contentDescription="@string/sync_status" /> android:contentDescription="@string/sync_status" />
</LinearLayout> </LinearLayout>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -4,6 +4,9 @@
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Adaptive Icon Background -->
<color name="ic_launcher_background">#f9e9c8</color>
<!-- Material 3 Light Theme Colors --> <!-- Material 3 Light Theme Colors -->
<color name="md_theme_light_primary">#6750A4</color> <color name="md_theme_light_primary">#6750A4</color>
<color name="md_theme_light_onPrimary">#FFFFFF</color> <color name="md_theme_light_onPrimary">#FFFFFF</color>

View File

@@ -57,4 +57,10 @@
<string name="restore_progress">Stelle Notizen wieder her…</string> <string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string> <string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string> <string name="restore_error">Fehler: %s</string>
<!-- Sync Status Banner (v1.3.1) -->
<string name="sync_status_syncing">Synchronisiere…</string>
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string>
</resources> </resources>

View File

@@ -2,4 +2,6 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.detekt) apply false
} }

View File

@@ -0,0 +1,136 @@
# ⚡ v1.3.1: detekt Configuration
# Pragmatic rules for simple-notes-sync
build:
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
excludeCorrectable: false
config:
validation: true
warningsAsErrors: false
comments:
CommentOverPrivateProperty:
active: false
UndocumentedPublicClass:
active: false
UndocumentedPublicFunction:
active: false
complexity:
ComplexCondition:
active: true
threshold: 5
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: true
LargeClass:
active: true
threshold: 600 # Increased for WebDavSyncService
LongMethod:
active: true
threshold: 80 # Increased for sync methods
LongParameterList:
active: true
functionThreshold: 6
constructorThreshold: 7
NestedBlockDepth:
active: true
threshold: 5
TooManyFunctions:
active: true
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 10
empty-blocks:
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: "_|(ignore|expected).*"
EmptyFunctionBlock:
active: true
ignoreOverridden: true
exceptions:
SwallowedException:
active: true
ignoredExceptionTypes:
- "InterruptedException"
- "MalformedURLException"
- "NumberFormatException"
- "ParseException"
TooGenericExceptionCaught:
active: true
exceptionNames:
- "Error"
- "Throwable"
allowedExceptionNameRegex: "_|(ignore|expected).*"
naming:
FunctionNaming:
active: true
functionPattern: "[a-zA-Z][a-zA-Z0-9]*"
VariableNaming:
active: true
variablePattern: "[a-z][A-Za-z0-9]*"
PackageNaming:
active: true
packagePattern: "[a-z]+(\\.[a-z][A-Za-z0-9]*)*"
performance:
SpreadOperator:
active: false # Spread operator is fine in most cases
potential-bugs:
CastToNullableType:
active: true
EqualsWithHashCodeExist:
active: true
UnconditionalJumpStatementInLoop:
active: true
style:
ForbiddenComment:
active: true
comments:
- "FIXME:"
- "STOPSHIP:"
allowedPatterns: ""
MagicNumber:
active: true
ignoreNumbers:
- "-1"
- "0"
- "1"
- "2"
- "100"
- "1000"
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: true
ignoreAnnotation: true
ignoreEnums: true
ignoreRanges: true
ignoreExtensionFunctions: true
MaxLineLength:
active: true
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludedFunctions: []
excludeLabeled: true
excludeReturnFromLambda: true
excludeGuardClauses: true
UnusedImports:
active: true
UnusedPrivateMember:
active: true
allowedNames: "_.*"
WildcardImport:
active: false # Allow wildcard imports

View File

@@ -9,6 +9,8 @@ appcompat = "1.6.1"
material = "1.10.0" material = "1.10.0"
activity = "1.8.0" activity = "1.8.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
ktlint = "12.1.0"
detekt = "1.23.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

View File

@@ -0,0 +1,13 @@
v1.3.1 - Multi-Device Sync Fix + Performance
Multi-Device JSON Sync (Danke Thomas!):
- JSON-Dateien syncen jetzt zwischen Geräten
- Funktioniert auch ohne Markdown aktiviert
- Keine doppelten Downloads mehr
Performance-Verbesserungen:
- Sync beschleunigt: 12-14s -> 2-3s
- Erster Sync nach MD-Export jetzt schnell
- JSON erreicht Markdown-Geschwindigkeit
+ Sync-Status-UI, Content MD-Import, Debug-Tools

View File

@@ -4,12 +4,14 @@ HAUPTFUNKTIONEN:
• Einfache Notizen erstellen und bearbeiten • Einfache Notizen erstellen und bearbeiten
• WebDAV-Synchronisation mit eigenem Server • WebDAV-Synchronisation mit eigenem Server
• Multi-Device Sync (Handy, Tablet, Desktop)
• Markdown-Export für Obsidian/Desktop-Editoren
• Automatische Synchronisation im Heim-WLAN • Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten) • Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige • Transparente Batterie-Verbrauchsanzeige
• Material Design 3 mit Dynamic Colors (Android 12+) • Material Design 3 mit Dynamic Colors (Android 12+)
• Swipe-to-Delete mit Bestätigungsdialog • Swipe-to-Delete mit Server-Sync
• Server-Backup & Wiederherstellung • Server-Backup & Wiederherstellung (Merge/Replace/Overwrite)
• Komplett offline nutzbar • Komplett offline nutzbar
• Keine Werbung, keine Tracker • Keine Werbung, keine Tracker
@@ -17,14 +19,23 @@ DATENSCHUTZ:
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools. Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
MULTI-DEVICE SYNC:
• Notizen synchronisieren automatisch zwischen allen Geräten
• Lösch-Tracking verhindert "Zombie-Notizen"
• Intelligente Konfliktlösung durch Timestamps
• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.)
• Änderungen von Desktop-Editoren werden automatisch importiert
SYNCHRONISATION: SYNCHRONISATION:
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.) • Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
• Konfigurierbares Interval: 15, 30 oder 60 Minuten • Konfigurierbares Interval: 15, 30 oder 60 Minuten
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min) • Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
• Doze Mode optimiert für zuverlässige Background-Syncs • Doze Mode optimiert für zuverlässige Background-Syncs
• Manuelle Synchronisation jederzeit möglich • Manuelle Synchronisation jederzeit möglich
• Konfliktfreie Zusammenführung durch Timestamps
MATERIAL DESIGN 3: MATERIAL DESIGN 3:
@@ -32,6 +43,7 @@ MATERIAL DESIGN 3:
• Dynamic Colors (Material You) auf Android 12+ • Dynamic Colors (Material You) auf Android 12+
• Dark Mode Support • Dark Mode Support
• Intuitive Gesten (Swipe-to-Delete) • Intuitive Gesten (Swipe-to-Delete)
• Live Sync-Status Anzeige
Open Source unter MIT-Lizenz Open Source unter MIT-Lizenz
Quellcode: https://github.com/inventory69/simple-notes-sync Quellcode: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,13 @@
v1.3.1 - Multi-Device Sync Fix + Performance
Multi-Device JSON Sync (Thanks Thomas!):
- JSON files now sync between devices
- Works without Markdown enabled
- No duplicate downloads anymore
Performance Improvements:
- Sync speed: 12-14s -> 2-3s
- First sync after MD export now fast
- JSON matches Markdown speed
+ Sync status UI, content MD import, debug tools

View File

@@ -4,12 +4,14 @@ KEY FEATURES:
• Create and edit simple notes • Create and edit simple notes
• WebDAV synchronization with your own server • WebDAV synchronization with your own server
• Multi-device sync (phone, tablet, desktop)
• Markdown export for Obsidian/desktop editors
• Automatic synchronization on home WiFi • Automatic synchronization on home WiFi
• Configurable sync interval (15/30/60 minutes) • Configurable sync interval (15/30/60 minutes)
• Transparent battery usage display • Transparent battery usage display
• Material Design 3 with Dynamic Colors (Android 12+) • Material Design 3 with Dynamic Colors (Android 12+)
• Swipe-to-delete with confirmation dialog • Swipe-to-delete with server sync
• Server backup & restore • Server backup & restore (Merge/Replace/Overwrite)
• Fully usable offline • Fully usable offline
• No ads, no trackers • No ads, no trackers
@@ -17,14 +19,23 @@ PRIVACY:
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools. Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
MULTI-DEVICE SYNC:
• Notes sync automatically between all your devices
• Deletion tracking prevents "zombie notes"
• Smart conflict resolution through timestamps
• Markdown files for desktop editing (Obsidian, VS Code, etc.)
• Changes from desktop editors are auto-imported
SYNCHRONIZATION: SYNCHRONIZATION:
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.) • Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
• Configurable interval: 15, 30, or 60 minutes • Configurable interval: 15, 30, or 60 minutes
• Optimized performance: skips unchanged files (~2-3s sync time)
• E-Tag caching for 20x faster "no changes" checks
• Measured battery consumption: only ~0.4% per day (at 30min) • Measured battery consumption: only ~0.4% per day (at 30min)
• Doze Mode optimized for reliable background syncs • Doze Mode optimized for reliable background syncs
• Manual synchronization available anytime • Manual synchronization available anytime
• Conflict-free merging through timestamps
MATERIAL DESIGN 3: MATERIAL DESIGN 3:
@@ -32,6 +43,7 @@ MATERIAL DESIGN 3:
• Dynamic Colors (Material You) on Android 12+ • Dynamic Colors (Material You) on Android 12+
• Dark Mode support • Dark Mode support
• Intuitive gestures (Swipe-to-delete) • Intuitive gestures (Swipe-to-delete)
• Live sync status indicator
Open Source under MIT License Open Source under MIT License
Source code: https://github.com/inventory69/simple-notes-sync Source code: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,8 +1,8 @@
Categories: Categories:
- Writing - Writing
License: MIT License: MIT
AuthorName: Liq Dettmer AuthorName: inventory69
AuthorEmail: liq@dettmer.dev AuthorEmail: admin@dettmer.dev
AuthorWebSite: https://dettmer.dev AuthorWebSite: https://dettmer.dev
SourceCode: https://github.com/inventory69/simple-notes-sync SourceCode: https://github.com/inventory69/simple-notes-sync
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
@@ -63,7 +63,63 @@ Builds:
scandelete: scandelete:
- android/gradle/wrapper - android/gradle/wrapper
- versionName: 1.2.1
versionCode: 6
commit: v1.2.1
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
- versionName: 1.2.2
versionCode: 7
commit: v1.2.2
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
- versionName: 1.3.0
versionCode: 8
commit: v1.3.0
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
- versionName: 1.3.1
versionCode: 9
commit: v1.3.1
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
AutoUpdateMode: Version AutoUpdateMode: Version
UpdateCheckMode: Tags UpdateCheckMode: Tags
CurrentVersion: 1.2.0 CurrentVersion: 1.3.1
CurrentVersionCode: 5 CurrentVersionCode: 9