Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1142afa2 | ||
|
|
359325bf64 | ||
|
|
c7d0f899e7 | ||
|
|
5121a7b2b8 | ||
|
|
04664c8920 |
87
CHANGELOG.md
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "") ?: ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 316 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 774 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 327 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
136
android/config/detekt/detekt.yml
Normal 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
|
||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
13
fastlane/metadata/android/de-DE/changelogs/9.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 93 KiB |
13
fastlane/metadata/android/en-US/changelogs/9.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 93 KiB |
@@ -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
|
||||||
|
|||||||