v1.3.1 - Multi-Device Sync Fix + Performance + Restore Bug Fix
🔧 Fixed: - Multi-device JSON sync now works (thanks Thomas!) - Restore from Server skipped files (timestamp bug) - No duplicate downloads - First MD sync after export now fast ⚡ Performance: - JSON sync: 12-14s → 2-3s - Hybrid timestamp + E-Tag optimization - Matches Markdown sync speed ✨ New: - Sync status UI in MainActivity - Content-based MD import - Debug logging improvements - SyncStateManager for sync coordination 🔧 Technical: - Clear lastSyncTimestamp on restore - Clear E-Tag caches on restore - E-Tag refresh after upload - Fixed timestamp update after MD export
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
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
|
||||
@@ -17,8 +20,8 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 8 // 🚀 v1.3.0: Multi-Device Sync with deletion tracking
|
||||
versionName = "1.3.0" // 🚀 v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import
|
||||
versionCode = 9 // 🚀 v1.3.1: Sync-Performance & Debug-Logging
|
||||
versionName = "1.3.1" // 🚀 v1.3.1: Skip unchanged MD-Files, Sync-Mutex, Debug-Logging UI
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -75,6 +78,16 @@ android {
|
||||
}
|
||||
|
||||
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 {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -143,4 +156,28 @@ dependencies {
|
||||
fun getBuildDate(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
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.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -50,9 +53,16 @@ class MainActivity : AppCompatActivity() {
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
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 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
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
@@ -97,9 +107,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// File Logging aktivieren wenn eingestellt
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
@@ -116,6 +127,65 @@ class MainActivity : AppCompatActivity() {
|
||||
setupFab()
|
||||
|
||||
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() {
|
||||
@@ -151,6 +221,12 @@ class MainActivity : AppCompatActivity() {
|
||||
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)")
|
||||
|
||||
// Update last sync timestamp
|
||||
@@ -163,6 +239,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -173,6 +250,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -184,6 +262,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
@@ -191,14 +270,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
@@ -235,6 +317,10 @@ class MainActivity : AppCompatActivity() {
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
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() {
|
||||
@@ -262,6 +348,12 @@ class MainActivity : AppCompatActivity() {
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
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 {
|
||||
try {
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
@@ -269,7 +361,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -278,15 +370,13 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
showToast("⚠️ Server nicht erreichbar")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -294,16 +384,14 @@ class MainActivity : AppCompatActivity() {
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("✅ ${result.syncedCount} Notizen synchronisiert")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
} else {
|
||||
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||
showToast("❌ Fehler: ${e.message}")
|
||||
} finally {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,6 +581,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Create sync service
|
||||
@@ -501,12 +594,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
return@launch
|
||||
}
|
||||
|
||||
showToast("Starte Synchronisation...")
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
@@ -514,7 +605,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
showToast("Server nicht erreichbar")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -525,20 +616,21 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes() // Reload notes
|
||||
} else {
|
||||
showToast("Sync Fehler: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
showToast("Sync Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
@@ -83,6 +84,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var cardDeveloperProfile: 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
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
@@ -124,6 +130,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
setupListeners()
|
||||
setupSyncIntervalPicker()
|
||||
setupAboutSection()
|
||||
setupDebugSection()
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
@@ -156,6 +163,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||
cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
|
||||
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
|
||||
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() {
|
||||
@@ -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
|
||||
*/
|
||||
@@ -467,6 +582,14 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
@@ -474,14 +597,16 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
SyncStateManager.markCompleted()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showToast("Synchronisiere...")
|
||||
showToast("🔄 Synchronisiere...")
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
||||
if (!syncService.isServerReachable()) {
|
||||
showToast("⚠️ Server nicht erreichbar")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
checkServerStatus() // Server-Status aktualisieren
|
||||
return@launch
|
||||
}
|
||||
@@ -490,18 +615,24 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (result.hasConflicts) {
|
||||
showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
|
||||
showToast("✅ Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
|
||||
} else {
|
||||
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
showToast("✅ Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
}
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
|
||||
} else {
|
||||
showToast("Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
showToast("❌ Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
checkServerStatus() // ✅ Auch bei Exception aktualisieren
|
||||
} finally {
|
||||
// Re-enable button
|
||||
buttonSyncNow.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,20 +955,20 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// Radio Buttons erstellen
|
||||
val radioMerge = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
||||
id = 0
|
||||
id = android.view.View.generateViewId()
|
||||
isChecked = true
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioReplace = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
||||
id = 1
|
||||
id = android.view.View.generateViewId()
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
||||
id = 2
|
||||
id = android.view.View.generateViewId()
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
@@ -876,8 +1007,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
.setView(mainLayout)
|
||||
.setPositiveButton("Wiederherstellen") { _, _ ->
|
||||
val selectedMode = when (radioGroup.checkedRadioButtonId) {
|
||||
1 -> RestoreMode.REPLACE
|
||||
2 -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
radioReplace.id -> RestoreMode.REPLACE
|
||||
radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
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.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.Inet4Address
|
||||
@@ -37,11 +38,20 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebDavSyncService"
|
||||
|
||||
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
|
||||
private val syncMutex = Mutex()
|
||||
}
|
||||
|
||||
private val storage: NotesStorage
|
||||
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
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 {
|
||||
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)
|
||||
*/
|
||||
private fun getWiFiInetAddress(): InetAddress? {
|
||||
private fun getWiFiInetAddressInternal(): InetAddress? {
|
||||
try {
|
||||
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 password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||
|
||||
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
|
||||
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
|
||||
|
||||
// Versuche WiFi-IP zu finden
|
||||
val wifiAddress = getWiFiInetAddress()
|
||||
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
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? {
|
||||
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
|
||||
*
|
||||
@@ -298,47 +380,13 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
// ====== JSON FILES CHECK (/notes/) ======
|
||||
|
||||
// Optimierung 1: E-Tag Check (fastest - ~100ms)
|
||||
val cachedETag = prefs.getString("notes_collection_etag", null)
|
||||
var jsonHasChanges = false
|
||||
// ⚡ v1.3.1: File-level E-Tag check in downloadRemoteNotes() is optimal!
|
||||
// Collection E-Tag doesn't work (server-dependent, doesn't track file changes)
|
||||
// → Always proceed to download phase where file-level E-Tags provide fast skips
|
||||
|
||||
if (cachedETag != null) {
|
||||
try {
|
||||
val resources = sardine.list(notesUrl, 0) // Depth 0 = only collection itself
|
||||
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
|
||||
}
|
||||
}
|
||||
// For hasUnsyncedChanges(): Conservative approach - assume changes may exist
|
||||
// Actual file-level E-Tag checks in downloadRemoteNotes() will skip unchanged files (0ms each)
|
||||
var hasJsonChanges = true // Assume yes, let file E-Tags optimize
|
||||
|
||||
// ====== MARKDOWN FILES CHECK (/notes-md/) ======
|
||||
// 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
|
||||
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
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -429,7 +485,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Perform intelligent server check
|
||||
val sardine = getSardine()
|
||||
val sardine = getOrCreateSardine()
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
if (sardine == null || serverUrl == null) {
|
||||
@@ -484,7 +540,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext SyncResult(
|
||||
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
|
||||
)
|
||||
@@ -529,18 +585,29 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
Logger.d(TAG, "🔄 syncNotes() ENTRY")
|
||||
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
|
||||
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
||||
// 🔒 v1.3.1: Verhindere parallele Syncs
|
||||
if (!syncMutex.tryLock()) {
|
||||
Logger.d(TAG, "⏭️ Sync already in progress - skipping")
|
||||
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 {
|
||||
Logger.d(TAG, "📍 Step 1: Getting Sardine client")
|
||||
|
||||
val sardine = try {
|
||||
getSardine()
|
||||
getOrCreateSardine()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in getSardine()!", e)
|
||||
Logger.e(TAG, "💥 CRASH in getOrCreateSardine()!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
@@ -571,20 +638,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
var conflictCount = 0
|
||||
|
||||
Logger.d(TAG, "📍 Step 3: Checking server directory")
|
||||
// Ensure notes/ directory exists
|
||||
// ⚡ v1.3.1: Verwende gecachte Directory-Checks
|
||||
val notesUrl = getNotesUrl(serverUrl)
|
||||
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")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
ensureNotesDirectoryExists(sardine, notesUrl)
|
||||
|
||||
// Ensure notes-md/ directory exists (for Markdown export)
|
||||
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 {
|
||||
@@ -712,13 +774,33 @@ class WebDavSyncService(private val context: Context) {
|
||||
val noteUrl = "$notesUrl${note.id}.json"
|
||||
val jsonBytes = note.toJson().toByteArray()
|
||||
|
||||
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
||||
sardine.put(noteUrl, jsonBytes, "application/json")
|
||||
Logger.d(TAG, " ✅ Upload successful")
|
||||
|
||||
// Update sync status
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||
storage.saveNote(updatedNote)
|
||||
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)
|
||||
// Läuft NACH erfolgreichem JSON-Upload
|
||||
if (markdownExportEnabled) {
|
||||
@@ -800,8 +882,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
): Int = withContext(Dispatchers.IO) {
|
||||
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
|
||||
|
||||
// Erstelle Sardine-Client mit gegebenen Credentials
|
||||
val wifiAddress = getWiFiInetAddress()
|
||||
// ⚡ v1.3.1: Use cached WiFi address
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
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")
|
||||
|
||||
// ⚡ 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
|
||||
}
|
||||
|
||||
@@ -886,17 +977,62 @@ class WebDavSyncService(private val context: Context) {
|
||||
val notesUrl = getNotesUrl(serverUrl)
|
||||
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)) {
|
||||
Logger.d(TAG, " ✅ /notes/ exists, scanning...")
|
||||
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) {
|
||||
if (resource.isDirectory || !resource.name.endsWith(".json")) {
|
||||
for (resource in jsonFiles) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 🔧 Fix: Build full URL instead of using href directly
|
||||
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
|
||||
// SECONDARY: E-Tag check (for performance after first sync)
|
||||
// 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 remoteNote = Note.fromJson(jsonContent) ?: continue
|
||||
|
||||
@@ -928,12 +1064,22 @@ class WebDavSyncService(private val context: Context) {
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
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 -> {
|
||||
// OVERWRITE mode: Always replace regardless of timestamps
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
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 -> {
|
||||
// Remote is newer
|
||||
@@ -946,11 +1092,16 @@ class WebDavSyncService(private val context: Context) {
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
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 {
|
||||
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
|
||||
}
|
||||
@@ -1065,36 +1216,14 @@ class WebDavSyncService(private val context: Context) {
|
||||
private fun saveLastSyncTimestamp() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// v1.3.0: Save E-Tag only for JSON (Markdown uses timestamp check)
|
||||
try {
|
||||
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
|
||||
// ⚡ v1.3.1: Simplified - file-level E-Tags cached individually in downloadRemoteNotes()
|
||||
// No need for collection E-Tag (doesn't work reliably across WebDAV servers)
|
||||
prefs.edit()
|
||||
.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()
|
||||
|
||||
Logger.d(TAG, "💾 Saved sync timestamp (file E-Tags cached individually)")
|
||||
}
|
||||
|
||||
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
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext RestoreResult(
|
||||
val sardine = getOrCreateSardine() ?: return@withContext RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert",
|
||||
restoredCount = 0
|
||||
@@ -1137,6 +1266,20 @@ class WebDavSyncService(private val context: Context) {
|
||||
Logger.d(TAG, "🗑️ Clearing deletion tracker (restore mode)")
|
||||
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
|
||||
val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES)
|
||||
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)
|
||||
* 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 {
|
||||
return try {
|
||||
@@ -1329,11 +1474,26 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") }
|
||||
var importedCount = 0
|
||||
var skippedCount = 0 // ⚡ v1.3.1: Zähle übersprungene Dateien
|
||||
|
||||
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) {
|
||||
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}")
|
||||
|
||||
// Build full URL
|
||||
@@ -1354,11 +1514,22 @@ class WebDavSyncService(private val context: Context) {
|
||||
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}"}")
|
||||
|
||||
// Use server file modification time for reliable change detection
|
||||
val serverModifiedTime = resource.modified?.time ?: 0L
|
||||
Logger.d(TAG, " Comparison: serverModified=$serverModifiedTime, localUpdated=${localNote?.updatedAt ?: 0L}")
|
||||
// ⚡ v1.3.1: Content-basierte Erkennung
|
||||
// Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde!
|
||||
// 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 {
|
||||
localNote == null -> {
|
||||
// New note from desktop
|
||||
@@ -1366,57 +1537,41 @@ class WebDavSyncService(private val context: Context) {
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}")
|
||||
}
|
||||
serverModifiedTime > localNote.updatedAt -> {
|
||||
// Server file is newer (based on modification time)
|
||||
Logger.d(TAG, " Decision: Server is newer!")
|
||||
// ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich
|
||||
localNote.syncStatus == SyncStatus.SYNCED && !contentChanged && localNote.updatedAt >= mdNote.updatedAt -> {
|
||||
// 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) {
|
||||
// Conflict: local has pending changes
|
||||
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
||||
Logger.w(TAG, " ⚠️ Conflict: Markdown vs local pending: ${mdNote.id}")
|
||||
} else {
|
||||
// Content comparison to preserve timestamps on export-only updates
|
||||
val contentChanged = mdNote.content != localNote.content ||
|
||||
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
|
||||
))
|
||||
// Import with the newer YAML timestamp
|
||||
storage.saveNote(mdNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
importedCount++
|
||||
|
||||
// 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}")
|
||||
}
|
||||
Logger.d(TAG, " ✅ Updated from Markdown (newer timestamp): ${mdNote.title}")
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -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
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -1493,7 +1649,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
*/
|
||||
suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext false
|
||||
val sardine = getOrCreateSardine() ?: return@withContext false
|
||||
val serverUrl = getServerUrl() ?: return@withContext false
|
||||
|
||||
var deletedJson = false
|
||||
@@ -1563,7 +1719,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
*/
|
||||
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
|
||||
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 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_DELETE_FROM_SERVER = "always_delete_from_server"
|
||||
|
||||
// 🔥 v1.3.1: Debug & Logging
|
||||
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -17,9 +17,32 @@ object Logger {
|
||||
|
||||
private var fileLoggingEnabled = false
|
||||
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 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
|
||||
*/
|
||||
@@ -50,11 +73,47 @@ object Logger {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
|
||||
@@ -22,6 +22,39 @@
|
||||
app:title="@string/app_name"
|
||||
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>
|
||||
|
||||
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
|
||||
|
||||
@@ -749,6 +749,96 @@
|
||||
|
||||
</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>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="?attr/colorError"
|
||||
app:tint="?attr/colorError"
|
||||
android:contentDescription="@string/delete" />
|
||||
|
||||
<!-- Title -->
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:tint="?attr/colorPrimary"
|
||||
app:tint="?attr/colorPrimary"
|
||||
android:contentDescription="@string/sync_status" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -57,4 +57,10 @@
|
||||
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
||||
<string name="restore_success">✓ %d Notizen wiederhergestellt</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>
|
||||
|
||||
Reference in New Issue
Block a user