feat: Konfigurierbare Sync-Intervalle + Über-Sektion (v1.1.0) (#1)
* feat: WiFi-Connect Auto-Sync + Debug Logging [skip ci]
- WiFi-Connect Auto-Sync via NetworkCallback + Broadcast (statt WorkManager)
- onResume Auto-Sync mit Toast-Feedback (nur Success)
- File-Logging Feature für Debugging (letzte 500 Einträge)
- Settings: Debug/Logs Section mit Test-Button
- FileProvider für Log-Sharing
- Extensive Debug-Logs für NetworkMonitor + MainActivity
- Material Design 3 Migration (alle 17 Tasks)
- Bug-Fixes: Input underlines, section rename, swipe-to-delete, flat cards
PROBLEM: WiFi-Connect sendet Broadcast aber MainActivity empfängt nicht
→ Benötigt logcat debugging auf anderem Gerät
* 🐛 fix: Remove WiFi-Connect related code and UI elements to streamline sync process
* feat: Konfigurierbare Sync-Intervalle + Über-Sektion (v1.1.0)
## Neue Features
### Konfigurierbare Sync-Intervalle
- Wählbare Intervalle: 15/30/60 Minuten in Settings
- Transparente Akkuverbrauchs-Anzeige (0.2-0.8% pro Tag)
- Sofortige Anwendung ohne App-Neustart
- NetworkMonitor liest Intervall dynamisch aus SharedPreferences
### Über-Sektion
- App-Version & Build-Datum Anzeige
- Klickbare Links zu GitHub Repository & Entwickler-Profil
- Lizenz-Information (MIT License)
- Ersetzt alte Debug/Logs Sektion
## Verbesserungen
- Benutzerfreundliche Doze-Mode Erklärung in Settings
- Keine störenden Sync-Fehler Toasts mehr im Hintergrund
- Modernisierte README mit Badges und kompakter Struktur
- F-Droid Metadaten aktualisiert (changelogs + descriptions)
## Technische Änderungen
- Version Bump: 1.0 → 1.1.0 (versionCode: 1 → 2)
- BUILD_DATE buildConfigField hinzugefügt
- PREF_SYNC_INTERVAL_MINUTES Konstante in Constants.kt
- NetworkMonitor.startPeriodicSync() nutzt konfigurierbare Intervalle
- SettingsActivity: setupSyncIntervalPicker() + setupAboutSection()
- activity_settings.xml: RadioGroup für Intervalle + About Cards
This commit is contained in:
@@ -13,39 +13,52 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var textViewEmpty: TextView
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
}
|
||||
|
||||
/**
|
||||
* BroadcastReceiver für Background-Sync Completion
|
||||
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
private val syncCompletedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -63,9 +76,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// File Logging aktivieren wenn eingestellt
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
}
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
@@ -82,14 +107,87 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
|
||||
|
||||
// Register BroadcastReceiver für Background-Sync
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered")
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
||||
triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatischer Sync (onResume)
|
||||
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
|
||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||
*
|
||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||
*/
|
||||
private fun triggerAutoSync(source: String = "unknown") {
|
||||
// Throttling: Max 1 Sync pro Minute
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
|
||||
*/
|
||||
private fun canTriggerAutoSync(): Boolean {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -102,7 +200,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun findViews() {
|
||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
||||
textViewEmpty = findViewById(R.id.textViewEmpty)
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
}
|
||||
@@ -117,6 +215,57 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
// Setup Swipe-to-Delete
|
||||
setupSwipeToDelete()
|
||||
}
|
||||
|
||||
private fun setupSwipeToDelete() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean = false
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.adapterPosition
|
||||
val note = adapter.currentList[position]
|
||||
val notesCopy = adapter.currentList.toMutableList()
|
||||
|
||||
// Remove from list immediately for visual feedback
|
||||
notesCopy.removeAt(position)
|
||||
adapter.submitList(notesCopy)
|
||||
|
||||
// Show Snackbar with UNDO
|
||||
Snackbar.make(
|
||||
recyclerViewNotes,
|
||||
"Notiz gelöscht",
|
||||
Snackbar.LENGTH_LONG
|
||||
).setAction("RÜCKGÄNGIG") {
|
||||
// UNDO: Restore note in list
|
||||
loadNotes()
|
||||
}.addCallback(object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO → Actually delete the note
|
||||
storage.deleteNote(note.id)
|
||||
loadNotes()
|
||||
}
|
||||
}
|
||||
}).show()
|
||||
}
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
// Require 80% swipe to trigger
|
||||
return 0.8f
|
||||
}
|
||||
})
|
||||
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
private fun setupFab() {
|
||||
@@ -129,8 +278,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val notes = storage.loadAllNotes()
|
||||
adapter.submitList(notes)
|
||||
|
||||
// Empty state
|
||||
textViewEmpty.visibility = if (notes.isEmpty()) {
|
||||
// Material 3 Empty State Card
|
||||
emptyStateCard.visibility = if (notes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
android.view.View.GONE
|
||||
@@ -146,7 +295,9 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
@@ -205,6 +356,16 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Restore was successful, reload notes
|
||||
loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
@@ -27,6 +28,10 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
setContentView(R.layout.activity_editor)
|
||||
|
||||
storage = NotesStorage(this)
|
||||
@@ -89,7 +94,7 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
showToast("Titel oder Inhalt darf nicht leer sein")
|
||||
showToast("Notiz ist leer")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,25 +10,43 @@ import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SettingsActivity"
|
||||
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
|
||||
private const val GITHUB_PROFILE_URL = "https://github.com/inventory69"
|
||||
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||
}
|
||||
|
||||
private lateinit var editTextServerUrl: EditText
|
||||
@@ -37,7 +55,20 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var switchAutoSync: SwitchCompat
|
||||
private lateinit var buttonTestConnection: Button
|
||||
private lateinit var buttonSyncNow: Button
|
||||
private lateinit var buttonRestoreFromServer: Button
|
||||
private lateinit var textViewServerStatus: TextView
|
||||
private lateinit var chipAutoSaveStatus: Chip
|
||||
|
||||
// Sync Interval UI
|
||||
private lateinit var radioGroupSyncInterval: RadioGroup
|
||||
|
||||
// About Section UI
|
||||
private lateinit var textViewAppVersion: TextView
|
||||
private lateinit var cardGitHubRepo: MaterialCardView
|
||||
private lateinit var cardDeveloperProfile: MaterialCardView
|
||||
private lateinit var cardLicense: MaterialCardView
|
||||
|
||||
private var autoSaveIndicatorJob: Job? = null
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||
@@ -45,6 +76,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
// Setup toolbar
|
||||
@@ -58,6 +93,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
findViews()
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
setupSyncIntervalPicker()
|
||||
setupAboutSection()
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
@@ -67,7 +104,18 @@ class SettingsActivity : AppCompatActivity() {
|
||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
||||
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
||||
chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus)
|
||||
|
||||
// Sync Interval UI
|
||||
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
|
||||
|
||||
// About Section UI
|
||||
textViewAppVersion = findViewById(R.id.textViewAppVersion)
|
||||
cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
|
||||
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
|
||||
cardLicense = findViewById(R.id.cardLicense)
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
@@ -91,16 +139,122 @@ class SettingsActivity : AppCompatActivity() {
|
||||
syncNow()
|
||||
}
|
||||
|
||||
buttonRestoreFromServer.setOnClickListener {
|
||||
saveSettings()
|
||||
showRestoreConfirmation()
|
||||
}
|
||||
|
||||
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||
onAutoSyncToggled(isChecked)
|
||||
showAutoSaveIndicator()
|
||||
}
|
||||
|
||||
// Server Status Check bei Settings-Änderung
|
||||
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) {
|
||||
checkServerStatus()
|
||||
showAutoSaveIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
editTextUsername.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) showAutoSaveIndicator()
|
||||
}
|
||||
|
||||
editTextPassword.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) showAutoSaveIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup sync interval picker with radio buttons
|
||||
*/
|
||||
private fun setupSyncIntervalPicker() {
|
||||
// Load current interval from preferences
|
||||
val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
// Set checked radio button based on current interval
|
||||
val checkedId = when (currentInterval) {
|
||||
15L -> R.id.radioInterval15
|
||||
30L -> R.id.radioInterval30
|
||||
60L -> R.id.radioInterval60
|
||||
else -> R.id.radioInterval30 // Default
|
||||
}
|
||||
radioGroupSyncInterval.check(checkedId)
|
||||
|
||||
// Listen for interval changes
|
||||
radioGroupSyncInterval.setOnCheckedChangeListener { _, checkedId ->
|
||||
val newInterval = when (checkedId) {
|
||||
R.id.radioInterval15 -> 15L
|
||||
R.id.radioInterval60 -> 60L
|
||||
else -> 30L // R.id.radioInterval30 or fallback
|
||||
}
|
||||
|
||||
// Save new interval to preferences
|
||||
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, newInterval).apply()
|
||||
|
||||
// Restart periodic sync with new interval (only if auto-sync is enabled)
|
||||
if (prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) {
|
||||
val networkMonitor = NetworkMonitor(this)
|
||||
networkMonitor.startMonitoring()
|
||||
|
||||
val intervalText = when (newInterval) {
|
||||
15L -> "15 Minuten"
|
||||
30L -> "30 Minuten"
|
||||
60L -> "60 Minuten"
|
||||
else -> "$newInterval Minuten"
|
||||
}
|
||||
showToast("⏱️ Sync-Intervall auf $intervalText geändert")
|
||||
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
|
||||
} else {
|
||||
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup about section with version info and clickable cards
|
||||
*/
|
||||
private fun setupAboutSection() {
|
||||
// Display app version with build date
|
||||
try {
|
||||
val versionName = BuildConfig.VERSION_NAME
|
||||
val versionCode = BuildConfig.VERSION_CODE
|
||||
val buildDate = BuildConfig.BUILD_DATE
|
||||
|
||||
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate"
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to load version info", e)
|
||||
textViewAppVersion.text = "Version nicht verfügbar"
|
||||
}
|
||||
|
||||
// GitHub Repository Card
|
||||
cardGitHubRepo.setOnClickListener {
|
||||
openUrl(GITHUB_REPO_URL)
|
||||
}
|
||||
|
||||
// Developer Profile Card
|
||||
cardDeveloperProfile.setOnClickListener {
|
||||
openUrl(GITHUB_PROFILE_URL)
|
||||
}
|
||||
|
||||
// License Card
|
||||
cardLicense.setOnClickListener {
|
||||
openUrl(LICENSE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens URL in browser
|
||||
*/
|
||||
private fun openUrl(url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to open URL: $url", e)
|
||||
showToast("❌ Fehler beim Öffnen des Links")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSettings() {
|
||||
@@ -122,11 +276,14 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("Verbindung erfolgreich!")
|
||||
checkServerStatus() // ✅ Server-Status sofort aktualisieren
|
||||
} else {
|
||||
showToast("Verbindung fehlgeschlagen: ${result.errorMessage}")
|
||||
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
checkServerStatus() // ✅ Auch bei Exception aktualisieren
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,11 +301,14 @@ class SettingsActivity : AppCompatActivity() {
|
||||
} else {
|
||||
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
}
|
||||
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
|
||||
} else {
|
||||
showToast("Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
checkServerStatus() // ✅ Auch bei Exception aktualisieren
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +420,75 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAutoSaveIndicator() {
|
||||
// Cancel previous job if still running
|
||||
autoSaveIndicatorJob?.cancel()
|
||||
|
||||
// Show saving indicator
|
||||
chipAutoSaveStatus.apply {
|
||||
visibility = android.view.View.VISIBLE
|
||||
text = "💾 Speichere..."
|
||||
setChipBackgroundColorResource(android.R.color.darker_gray)
|
||||
}
|
||||
|
||||
// Save settings
|
||||
saveSettings()
|
||||
|
||||
// Show saved confirmation after short delay
|
||||
autoSaveIndicatorJob = lifecycleScope.launch {
|
||||
delay(300) // Short delay to show "Speichere..."
|
||||
chipAutoSaveStatus.apply {
|
||||
text = "✓ Gespeichert"
|
||||
setChipBackgroundColorResource(android.R.color.holo_green_light)
|
||||
}
|
||||
delay(2000) // Show for 2 seconds
|
||||
chipAutoSaveStatus.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRestoreConfirmation() {
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle(R.string.restore_confirmation_title)
|
||||
.setMessage(R.string.restore_confirmation_message)
|
||||
.setPositiveButton(R.string.restore_button) { _, _ ->
|
||||
performRestore()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performRestore() {
|
||||
val progressDialog = android.app.ProgressDialog(this).apply {
|
||||
setMessage(getString(R.string.restore_progress))
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
val webdavService = WebDavSyncService(this@SettingsActivity)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
webdavService.restoreFromServer()
|
||||
}
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast(getString(R.string.restore_success, result.restoredCount))
|
||||
// Refresh MainActivity's note list
|
||||
setResult(RESULT_OK)
|
||||
} else {
|
||||
showToast(getString(R.string.restore_error, result.errorMessage))
|
||||
}
|
||||
checkServerStatus()
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
showToast(getString(R.string.restore_error, e.message))
|
||||
checkServerStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
|
||||
class SimpleNotesApplication : Application() {
|
||||
|
||||
@@ -16,6 +18,13 @@ class SimpleNotesApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Application onCreate()")
|
||||
|
||||
// Initialize notification channel
|
||||
|
||||
@@ -37,5 +37,16 @@ class NotesStorage(private val context: Context) {
|
||||
return file.delete()
|
||||
}
|
||||
|
||||
fun deleteAllNotes(): Boolean {
|
||||
return try {
|
||||
notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.forEach { it.delete() }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.work.*
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* NetworkMonitor: Verwaltet WorkManager-basiertes Auto-Sync
|
||||
* WICHTIG: Kein NetworkCallback mehr - WorkManager macht das für uns!
|
||||
* NetworkMonitor: Verwaltet Auto-Sync
|
||||
* - Periodic WorkManager für Auto-Sync alle 30min
|
||||
* - NetworkCallback für WiFi-Connect Detection → WorkManager OneTime Sync
|
||||
*/
|
||||
class NetworkMonitor(private val context: Context) {
|
||||
|
||||
@@ -22,30 +26,145 @@ class NetworkMonitor(private val context: Context) {
|
||||
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private val connectivityManager by lazy {
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
// 🔥 Track last connected network ID to detect network changes (SSID wechsel, WiFi an/aus)
|
||||
// null = kein Netzwerk, sonst Network.toString() als eindeutiger Identifier
|
||||
private var lastConnectedNetworkId: String? = null
|
||||
|
||||
/**
|
||||
* Startet WorkManager mit Network Constraints
|
||||
* WorkManager kümmert sich automatisch um WiFi-Erkennung!
|
||||
* NetworkCallback: Erkennt WiFi-Verbindung und triggert WorkManager
|
||||
* WorkManager funktioniert auch wenn App geschlossen ist!
|
||||
*/
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
Logger.d(TAG, "🌐 NetworkCallback.onAvailable() triggered")
|
||||
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
||||
Logger.d(TAG, " Network capabilities: $capabilities")
|
||||
|
||||
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
Logger.d(TAG, " Is WiFi: $isWifi")
|
||||
|
||||
if (isWifi) {
|
||||
val currentNetworkId = network.toString()
|
||||
Logger.d(TAG, "📶 WiFi network connected: $currentNetworkId")
|
||||
|
||||
// 🔥 Trigger bei:
|
||||
// 1. WiFi aus -> WiFi an (lastConnectedNetworkId == null)
|
||||
// 2. SSID-Wechsel (lastConnectedNetworkId != currentNetworkId)
|
||||
// NICHT triggern bei: App-Restart mit gleichem WiFi
|
||||
|
||||
if (lastConnectedNetworkId != currentNetworkId) {
|
||||
if (lastConnectedNetworkId == null) {
|
||||
Logger.d(TAG, " 🎯 WiFi state changed: OFF -> ON (network: $currentNetworkId)")
|
||||
} else {
|
||||
Logger.d(TAG, " 🎯 WiFi network changed: $lastConnectedNetworkId -> $currentNetworkId")
|
||||
}
|
||||
|
||||
lastConnectedNetworkId = currentNetworkId
|
||||
|
||||
// Auto-Sync check
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
|
||||
|
||||
if (autoSyncEnabled) {
|
||||
Logger.d(TAG, " ✅ Triggering WorkManager...")
|
||||
triggerWifiConnectSync()
|
||||
} else {
|
||||
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, " ⚠️ Not WiFi - ignoring")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
|
||||
val lostNetworkId = network.toString()
|
||||
Logger.d(TAG, "🔴 NetworkCallback.onLost() - Network disconnected: $lostNetworkId")
|
||||
|
||||
if (lastConnectedNetworkId == lostNetworkId) {
|
||||
Logger.d(TAG, " Last WiFi network lost - resetting state")
|
||||
lastConnectedNetworkId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggert WiFi-Connect Sync via WorkManager
|
||||
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
|
||||
*/
|
||||
private fun triggerWifiConnectSync() {
|
||||
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
|
||||
|
||||
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
|
||||
// Ohne Constraint könnte WorkManager den Job auf Cellular ausführen
|
||||
// (z.B. wenn WiFi disconnected bevor Job startet)
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only!
|
||||
.build()
|
||||
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.setConstraints(constraints) // 🔥 Constraints hinzugefügt
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.addTag("wifi-connect")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncRequest)
|
||||
Logger.d(TAG, "✅ WiFi-Connect sync scheduled (WIFI ONLY, WorkManager will wake app if needed)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet WorkManager mit Network Constraints + NetworkCallback
|
||||
*/
|
||||
fun startMonitoring() {
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "Auto-sync disabled - stopping periodic work")
|
||||
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
|
||||
stopMonitoring()
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Starting WorkManager-based auto-sync")
|
||||
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
|
||||
|
||||
// 1. WorkManager für periodic sync
|
||||
startPeriodicSync()
|
||||
|
||||
// 2. NetworkCallback für WiFi-Connect Detection
|
||||
startWifiMonitoring()
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet WorkManager periodic sync
|
||||
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
|
||||
*/
|
||||
private fun startPeriodicSync() {
|
||||
// 🔥 Interval aus SharedPrefs lesen
|
||||
val intervalMinutes = prefs.getLong(
|
||||
Constants.PREF_SYNC_INTERVAL_MINUTES,
|
||||
Constants.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
)
|
||||
|
||||
Logger.d(TAG, "📅 Configuring periodic sync: ${intervalMinutes}min interval")
|
||||
|
||||
// Constraints: Nur wenn WiFi connected
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
|
||||
.build()
|
||||
|
||||
// Periodic Work Request - prüft alle 30 Minuten (Battery optimized)
|
||||
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
30, TimeUnit.MINUTES, // Optimiert: 30 Min statt 15 Min
|
||||
10, TimeUnit.MINUTES // Flex interval
|
||||
intervalMinutes, TimeUnit.MINUTES, // 🔥 Dynamisch!
|
||||
5, TimeUnit.MINUTES // Flex interval
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
@@ -53,107 +172,103 @@ class NetworkMonitor(private val context: Context) {
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
AUTO_SYNC_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE statt KEEP für immediate trigger
|
||||
ExistingPeriodicWorkPolicy.UPDATE, // 🔥 Update bei Interval-Änderung
|
||||
syncRequest
|
||||
)
|
||||
|
||||
Logger.d(TAG, "✅ Periodic auto-sync scheduled (every 30min when on WiFi)")
|
||||
|
||||
// Trigger sofortigen Sync wenn WiFi bereits connected
|
||||
triggerImmediateSync()
|
||||
Logger.d(TAG, "✅ Periodic sync scheduled (every ${intervalMinutes}min)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt WorkManager Auto-Sync
|
||||
* Startet NetworkCallback für WiFi-Connect Detection
|
||||
*/
|
||||
private fun startWifiMonitoring() {
|
||||
try {
|
||||
Logger.d(TAG, "🚀 Starting WiFi monitoring...")
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
Logger.d(TAG, " NetworkRequest built: WIFI + INTERNET capability")
|
||||
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||||
Logger.d(TAG, "✅✅✅ WiFi NetworkCallback registered successfully")
|
||||
Logger.d(TAG, " Callback will trigger on WiFi connect/disconnect")
|
||||
|
||||
// 🔥 FIX: Initialisiere wasWifiConnected State beim Start
|
||||
// onAvailable() wird nur bei NEUEN Verbindungen getriggert!
|
||||
initializeWifiState()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌❌❌ Failed to register NetworkCallback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert lastConnectedNetworkId beim App-Start
|
||||
* Wichtig damit wir echte Netzwerk-Wechsel von App-Restarts unterscheiden können
|
||||
*/
|
||||
private fun initializeWifiState() {
|
||||
try {
|
||||
Logger.d(TAG, "🔍 Initializing WiFi state...")
|
||||
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
if (activeNetwork == null) {
|
||||
Logger.d(TAG, " ❌ No active network - lastConnectedNetworkId = null")
|
||||
lastConnectedNetworkId = null
|
||||
return
|
||||
}
|
||||
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
|
||||
if (isWifi) {
|
||||
lastConnectedNetworkId = activeNetwork.toString()
|
||||
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
||||
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
|
||||
} else {
|
||||
lastConnectedNetworkId = null
|
||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Error initializing WiFi state", e)
|
||||
lastConnectedNetworkId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob WiFi aktuell verbunden ist
|
||||
* @return true wenn WiFi verbunden, false sonst (Cellular, offline, etc.)
|
||||
*/
|
||||
fun isWiFiConnected(): Boolean {
|
||||
return try {
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error checking WiFi status", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt WorkManager Auto-Sync + NetworkCallback
|
||||
*/
|
||||
fun stopMonitoring() {
|
||||
Logger.d(TAG, "🛑 Stopping auto-sync")
|
||||
|
||||
// Stop WorkManager
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger sofortigen Sync (z.B. nach Settings-Änderung)
|
||||
*/
|
||||
private fun triggerImmediateSync() {
|
||||
if (!isConnectedToHomeWifi()) {
|
||||
Logger.d(TAG, "Not on home WiFi - skipping immediate sync")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "<EFBFBD> Triggering immediate sync...")
|
||||
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob connected zu Home WiFi via Gateway IP Check
|
||||
*/
|
||||
private fun isConnectedToHomeWifi(): Boolean {
|
||||
val gatewayIP = getGatewayIP() ?: return false
|
||||
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty()) return false
|
||||
|
||||
val serverIP = extractIPFromUrl(serverUrl)
|
||||
if (serverIP == null) return false
|
||||
|
||||
val sameNetwork = isSameNetwork(gatewayIP, serverIP)
|
||||
Logger.d(TAG, "Gateway: $gatewayIP, Server: $serverIP → Same network: $sameNetwork")
|
||||
|
||||
return sameNetwork
|
||||
}
|
||||
|
||||
private fun getGatewayIP(): String? {
|
||||
return try {
|
||||
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
|
||||
as WifiManager
|
||||
val dhcpInfo = wifiManager.dhcpInfo
|
||||
val gateway = dhcpInfo.gateway
|
||||
|
||||
val ip = String.format(
|
||||
"%d.%d.%d.%d",
|
||||
gateway and 0xFF,
|
||||
(gateway shr 8) and 0xFF,
|
||||
(gateway shr 16) and 0xFF,
|
||||
(gateway shr 24) and 0xFF
|
||||
)
|
||||
ip
|
||||
// Unregister NetworkCallback
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to get gateway IP: ${e.message}")
|
||||
null
|
||||
// Already unregistered
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractIPFromUrl(url: String): String? {
|
||||
return try {
|
||||
val urlObj = java.net.URL(url)
|
||||
val host = urlObj.host
|
||||
|
||||
if (host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) {
|
||||
host
|
||||
} else {
|
||||
val addr = java.net.InetAddress.getByName(host)
|
||||
addr.hostAddress
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to extract IP: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameNetwork(ip1: String, ip2: String): Boolean {
|
||||
val parts1 = ip1.split(".")
|
||||
val parts2 = ip2.split(".")
|
||||
|
||||
if (parts1.size != 4 || parts2.size != 4) return false
|
||||
|
||||
return parts1[0] == parts2[0] &&
|
||||
parts1[1] == parts2[1] &&
|
||||
parts1[2] == parts2[2]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -21,25 +22,72 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
Logger.d(TAG, "🔄 SyncWorker started")
|
||||
Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
|
||||
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
Logger.d(TAG, "🔄 SyncWorker.doWork() ENTRY")
|
||||
Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
|
||||
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
||||
Logger.d(TAG, "RunAttempt: $runAttemptCount")
|
||||
}
|
||||
|
||||
return@withContext try {
|
||||
// Start sync (kein "in progress" notification mehr)
|
||||
val syncService = WebDavSyncService(applicationContext)
|
||||
Logger.d(TAG, "🚀 Starting sync...")
|
||||
Logger.d(TAG, "📊 Attempt: ${runAttemptCount}")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 1: Before WebDavSyncService creation")
|
||||
}
|
||||
|
||||
val result = syncService.syncNotes()
|
||||
// Try-catch um Service-Creation
|
||||
val syncService = try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Creating WebDavSyncService with applicationContext...")
|
||||
}
|
||||
WebDavSyncService(applicationContext).also {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " ✅ WebDavSyncService created successfully")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in WebDavSyncService constructor!", e)
|
||||
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2: Before syncNotes() call")
|
||||
Logger.d(TAG, " SyncService: $syncService")
|
||||
}
|
||||
|
||||
// Try-catch um syncNotes
|
||||
val result = try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Calling syncService.syncNotes()...")
|
||||
}
|
||||
syncService.syncNotes().also {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " ✅ syncNotes() returned")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in syncNotes()!", e)
|
||||
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Processing result")
|
||||
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
Logger.d(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Success path")
|
||||
}
|
||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
|
||||
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
||||
if (result.syncedCount > 0) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Showing success notification...")
|
||||
}
|
||||
NotificationHelper.showSyncSuccess(
|
||||
applicationContext,
|
||||
result.syncedCount
|
||||
@@ -49,10 +97,20 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
// **UI REFRESH**: Broadcast für MainActivity
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Broadcasting sync completed...")
|
||||
}
|
||||
broadcastSyncCompleted(true, result.syncedCount)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
Result.success()
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Failure path")
|
||||
}
|
||||
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
|
||||
NotificationHelper.showSyncError(
|
||||
applicationContext,
|
||||
@@ -62,19 +120,39 @@ class SyncWorker(
|
||||
// Broadcast auch bei Fehler (damit UI refresht)
|
||||
broadcastSyncCompleted(false, 0)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "❌ SyncWorker.doWork() FAILURE")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Sync exception: ${e.message}", e)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
Logger.e(TAG, "💥💥💥 FATAL EXCEPTION in doWork() 💥💥💥")
|
||||
Logger.e(TAG, "Exception type: ${e.javaClass.name}")
|
||||
Logger.e(TAG, "Exception message: ${e.message}")
|
||||
Logger.e(TAG, "Stack trace:", e)
|
||||
NotificationHelper.showSyncError(
|
||||
applicationContext,
|
||||
e.message ?: "Unknown error"
|
||||
)
|
||||
|
||||
broadcastSyncCompleted(false, 0)
|
||||
try {
|
||||
NotificationHelper.showSyncError(
|
||||
applicationContext,
|
||||
e.message ?: "Unknown error"
|
||||
)
|
||||
} catch (notifError: Exception) {
|
||||
Logger.e(TAG, "Failed to show error notification", notifError)
|
||||
}
|
||||
|
||||
try {
|
||||
broadcastSyncCompleted(false, 0)
|
||||
} catch (broadcastError: Exception) {
|
||||
Logger.e(TAG, "Failed to broadcast", broadcastError)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
@@ -10,6 +13,14 @@ import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import javax.net.SocketFactory
|
||||
|
||||
class WebDavSyncService(private val context: Context) {
|
||||
|
||||
@@ -17,17 +28,158 @@ class WebDavSyncService(private val context: Context) {
|
||||
private const val TAG = "WebDavSyncService"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val storage: NotesStorage
|
||||
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
Logger.d(TAG, "🏗️ WebDavSyncService INIT")
|
||||
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
|
||||
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
||||
}
|
||||
|
||||
try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Creating NotesStorage...")
|
||||
}
|
||||
storage = NotesStorage(context)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " ✅ NotesStorage created successfully")
|
||||
Logger.d(TAG, " Notes dir: ${storage.getNotesDir()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in NotesStorage creation!", e)
|
||||
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " SharedPreferences: $prefs")
|
||||
Logger.d(TAG, "✅ WebDavSyncService INIT complete")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
|
||||
*/
|
||||
private fun getWiFiInetAddress(): InetAddress? {
|
||||
try {
|
||||
Logger.d(TAG, "🔍 getWiFiInetAddress() called")
|
||||
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork
|
||||
Logger.d(TAG, " Active network: $network")
|
||||
|
||||
if (network == null) {
|
||||
Logger.d(TAG, "❌ No active network")
|
||||
return null
|
||||
}
|
||||
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
||||
Logger.d(TAG, " Network capabilities: $capabilities")
|
||||
|
||||
if (capabilities == null) {
|
||||
Logger.d(TAG, "❌ No network capabilities")
|
||||
return null
|
||||
}
|
||||
|
||||
// Nur wenn WiFi aktiv
|
||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
||||
return null
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
|
||||
|
||||
// Finde WiFi Interface
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val iface = interfaces.nextElement()
|
||||
|
||||
Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}")
|
||||
|
||||
// WiFi Interfaces: wlan0, wlan1, etc.
|
||||
if (!iface.name.startsWith("wlan")) continue
|
||||
if (!iface.isUp) continue
|
||||
|
||||
val addresses = iface.inetAddresses
|
||||
while (addresses.hasMoreElements()) {
|
||||
val addr = addresses.nextElement()
|
||||
|
||||
Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}")
|
||||
|
||||
// Nur IPv4, nicht loopback, nicht link-local
|
||||
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
|
||||
Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
|
||||
return addr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.w(TAG, "⚠️ No WiFi interface found, using default routing")
|
||||
return null
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Failed to get WiFi interface", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom SocketFactory die an WiFi-IP bindet (VPN Fix)
|
||||
*/
|
||||
private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() {
|
||||
override fun createSocket(): Socket {
|
||||
val socket = Socket()
|
||||
socket.bind(InetSocketAddress(wifiAddress, 0))
|
||||
Logger.d(TAG, "🔌 Socket bound to WiFi IP: ${wifiAddress.hostAddress}")
|
||||
return socket
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int): Socket {
|
||||
val socket = createSocket()
|
||||
socket.connect(InetSocketAddress(host, port))
|
||||
return socket
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
|
||||
return createSocket(host, port)
|
||||
}
|
||||
|
||||
override fun createSocket(host: InetAddress, port: Int): Socket {
|
||||
val socket = createSocket()
|
||||
socket.connect(InetSocketAddress(host, port))
|
||||
return socket
|
||||
}
|
||||
|
||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
|
||||
return createSocket(address, port)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSardine(): Sardine? {
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||
|
||||
// Einfach standard OkHttpSardine - funktioniert im manuellen Sync!
|
||||
android.util.Log.d(TAG, "🔧 Creating OkHttpSardine")
|
||||
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
|
||||
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
|
||||
|
||||
return OkHttpSardine().apply {
|
||||
// Versuche WiFi-IP zu finden
|
||||
val wifiAddress = getWiFiInetAddress()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
|
||||
OkHttpClient.Builder()
|
||||
.socketFactory(WiFiSocketFactory(wifiAddress))
|
||||
.build()
|
||||
} else {
|
||||
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
|
||||
OkHttpClient.Builder().build()
|
||||
}
|
||||
|
||||
return OkHttpSardine(okHttpClient).apply {
|
||||
setCredentials(username, password)
|
||||
}
|
||||
}
|
||||
@@ -83,58 +235,102 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
|
||||
android.util.Log.d(TAG, "🔄 syncNotes() called")
|
||||
android.util.Log.d(TAG, "Context: ${context.javaClass.simpleName}")
|
||||
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 {
|
||||
val sardine = getSardine()
|
||||
Logger.d(TAG, "📍 Step 1: Getting Sardine client")
|
||||
|
||||
val sardine = try {
|
||||
getSardine()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in getSardine()!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (sardine == null) {
|
||||
android.util.Log.e(TAG, "❌ Sardine is null - credentials missing")
|
||||
Logger.e(TAG, "❌ Sardine is null - credentials missing")
|
||||
return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
|
||||
)
|
||||
}
|
||||
Logger.d(TAG, " ✅ Sardine client created")
|
||||
|
||||
Logger.d(TAG, "📍 Step 2: Getting server URL")
|
||||
val serverUrl = getServerUrl()
|
||||
if (serverUrl == null) {
|
||||
android.util.Log.e(TAG, "❌ Server URL is null")
|
||||
Logger.e(TAG, "❌ Server URL is null")
|
||||
return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-URL nicht konfiguriert"
|
||||
)
|
||||
}
|
||||
|
||||
android.util.Log.d(TAG, "📡 Server URL: $serverUrl")
|
||||
android.util.Log.d(TAG, "🔐 Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
|
||||
Logger.d(TAG, "📡 Server URL: $serverUrl")
|
||||
Logger.d(TAG, "🔐 Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
|
||||
|
||||
var syncedCount = 0
|
||||
var conflictCount = 0
|
||||
|
||||
Logger.d(TAG, "📍 Step 3: Checking server directory")
|
||||
// Ensure server directory exists
|
||||
android.util.Log.d(TAG, "🔍 Checking if server directory exists...")
|
||||
if (!sardine.exists(serverUrl)) {
|
||||
android.util.Log.d(TAG, "📁 Creating server directory...")
|
||||
sardine.createDirectory(serverUrl)
|
||||
try {
|
||||
Logger.d(TAG, "🔍 Checking if server directory exists...")
|
||||
if (!sardine.exists(serverUrl)) {
|
||||
Logger.d(TAG, "📁 Creating server directory...")
|
||||
sardine.createDirectory(serverUrl)
|
||||
}
|
||||
Logger.d(TAG, " ✅ Server directory ready")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH checking/creating server directory!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📍 Step 4: Uploading local notes")
|
||||
// Upload local notes
|
||||
android.util.Log.d(TAG, "⬆️ Uploading local notes...")
|
||||
val uploadedCount = uploadLocalNotes(sardine, serverUrl)
|
||||
syncedCount += uploadedCount
|
||||
android.util.Log.d(TAG, "✅ Uploaded: $uploadedCount notes")
|
||||
try {
|
||||
Logger.d(TAG, "⬆️ Uploading local notes...")
|
||||
val uploadedCount = uploadLocalNotes(sardine, serverUrl)
|
||||
syncedCount += uploadedCount
|
||||
Logger.d(TAG, "✅ Uploaded: $uploadedCount notes")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in uploadLocalNotes()!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📍 Step 5: Downloading remote notes")
|
||||
// Download remote notes
|
||||
android.util.Log.d(TAG, "⬇️ Downloading remote notes...")
|
||||
val downloadResult = downloadRemoteNotes(sardine, serverUrl)
|
||||
syncedCount += downloadResult.downloadedCount
|
||||
conflictCount += downloadResult.conflictCount
|
||||
android.util.Log.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
|
||||
try {
|
||||
Logger.d(TAG, "⬇️ Downloading remote notes...")
|
||||
val downloadResult = downloadRemoteNotes(sardine, serverUrl)
|
||||
syncedCount += downloadResult.downloadedCount
|
||||
conflictCount += downloadResult.conflictCount
|
||||
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📍 Step 6: Saving sync timestamp")
|
||||
// Update last sync timestamp
|
||||
saveLastSyncTimestamp()
|
||||
try {
|
||||
saveLastSyncTimestamp()
|
||||
Logger.d(TAG, " ✅ Timestamp saved")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH saving timestamp!", e)
|
||||
e.printStackTrace()
|
||||
// Non-fatal, continue
|
||||
}
|
||||
|
||||
android.util.Log.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount")
|
||||
Logger.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
|
||||
SyncResult(
|
||||
isSuccess = true,
|
||||
@@ -143,8 +339,13 @@ class WebDavSyncService(private val context: Context) {
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "💥 Sync exception: ${e.message}", e)
|
||||
android.util.Log.e(TAG, "Exception type: ${e.javaClass.name}")
|
||||
Logger.e(TAG, "═══════════════════════════════════════")
|
||||
Logger.e(TAG, "💥💥💥 FATAL EXCEPTION in syncNotes() 💥💥💥")
|
||||
Logger.e(TAG, "Exception type: ${e.javaClass.name}")
|
||||
Logger.e(TAG, "Exception message: ${e.message}")
|
||||
Logger.e(TAG, "Stack trace:")
|
||||
e.printStackTrace()
|
||||
Logger.e(TAG, "═══════════════════════════════════════")
|
||||
|
||||
SyncResult(
|
||||
isSuccess = false,
|
||||
@@ -253,4 +454,95 @@ class WebDavSyncService(private val context: Context) {
|
||||
fun getLastSyncTimestamp(): Long {
|
||||
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all notes from server - overwrites local storage
|
||||
* @return RestoreResult with count of restored notes
|
||||
*/
|
||||
suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert",
|
||||
restoredCount = 0
|
||||
)
|
||||
|
||||
val serverUrl = getServerUrl() ?: return@withContext RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-URL nicht konfiguriert",
|
||||
restoredCount = 0
|
||||
)
|
||||
|
||||
Logger.d(TAG, "🔄 Starting restore from server...")
|
||||
|
||||
// List all files on server
|
||||
val resources = sardine.list(serverUrl)
|
||||
val jsonFiles = resources.filter {
|
||||
!it.isDirectory && it.name.endsWith(".json")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📂 Found ${jsonFiles.size} files on server")
|
||||
|
||||
val restoredNotes = mutableListOf<Note>()
|
||||
|
||||
// Download and parse each file
|
||||
for (resource in jsonFiles) {
|
||||
try {
|
||||
val fileUrl = serverUrl.trimEnd('/') + "/" + resource.name
|
||||
val content = sardine.get(fileUrl).bufferedReader().use { it.readText() }
|
||||
|
||||
val note = Note.fromJson(content)
|
||||
if (note != null) {
|
||||
restoredNotes.add(note)
|
||||
Logger.d(TAG, "✅ Downloaded: ${note.title}")
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Failed to parse ${resource.name}: Note.fromJson returned null")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Failed to download ${resource.name}", e)
|
||||
// Continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredNotes.isEmpty()) {
|
||||
return@withContext RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Keine Notizen auf Server gefunden",
|
||||
restoredCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
// Clear local storage
|
||||
Logger.d(TAG, "🗑️ Clearing local storage...")
|
||||
storage.deleteAllNotes()
|
||||
|
||||
// Save all restored notes
|
||||
Logger.d(TAG, "💾 Saving ${restoredNotes.size} notes...")
|
||||
restoredNotes.forEach { note ->
|
||||
storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Restore completed: ${restoredNotes.size} notes")
|
||||
|
||||
RestoreResult(
|
||||
isSuccess = true,
|
||||
errorMessage = null,
|
||||
restoredCount = restoredNotes.size
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Restore failed", e)
|
||||
RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = e.message ?: "Unbekannter Fehler",
|
||||
restoredCount = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RestoreResult(
|
||||
val isSuccess: Boolean,
|
||||
val errorMessage: String?,
|
||||
val restoredCount: Int
|
||||
)
|
||||
|
||||
@@ -10,6 +10,10 @@ object Constants {
|
||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||
|
||||
// 🔥 NEU: Sync Interval Configuration
|
||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -1,30 +1,122 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Logger: Debug logs nur bei DEBUG builds
|
||||
* Logger: Debug logs nur bei DEBUG builds + File Logging
|
||||
* Release builds zeigen nur Errors/Warnings
|
||||
*/
|
||||
object Logger {
|
||||
|
||||
private var fileLoggingEnabled = false
|
||||
private var logFile: File? = null
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
private val maxLogEntries = 500 // Nur letzte 500 Einträge
|
||||
|
||||
/**
|
||||
* Aktiviert File-Logging für Debugging
|
||||
*/
|
||||
fun enableFileLogging(context: Context) {
|
||||
try {
|
||||
logFile = File(context.filesDir, "simplenotes_debug.log")
|
||||
fileLoggingEnabled = true
|
||||
|
||||
// Clear old log
|
||||
logFile?.writeText("")
|
||||
|
||||
i("Logger", "📝 File logging enabled: ${logFile?.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("Logger", "Failed to enable file logging", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktiviert File-Logging
|
||||
*/
|
||||
fun disableFileLogging() {
|
||||
fileLoggingEnabled = false
|
||||
i("Logger", "📝 File logging disabled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Log-Datei zurück
|
||||
*/
|
||||
fun getLogFile(): File? = logFile
|
||||
|
||||
/**
|
||||
* Schreibt Log-Eintrag in Datei
|
||||
*/
|
||||
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
|
||||
if (!fileLoggingEnabled || logFile == null) return
|
||||
|
||||
try {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
val logEntry = buildString {
|
||||
append("$timestamp [$level] $tag: $message\n")
|
||||
throwable?.let {
|
||||
append(" Exception: ${it.message}\n")
|
||||
append(" ${it.stackTraceToString()}\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Append to file
|
||||
FileWriter(logFile, true).use { writer ->
|
||||
writer.write(logEntry)
|
||||
}
|
||||
|
||||
// Trim file if too large
|
||||
trimLogFile()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Logger", "Failed to write to log file", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begrenzt Log-Datei auf maxLogEntries
|
||||
*/
|
||||
private fun trimLogFile() {
|
||||
try {
|
||||
val lines = logFile?.readLines() ?: return
|
||||
if (lines.size > maxLogEntries) {
|
||||
val trimmed = lines.takeLast(maxLogEntries)
|
||||
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Logger", "Failed to trim log file", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun d(tag: String, message: String) {
|
||||
// Logcat nur in DEBUG builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(tag, message)
|
||||
}
|
||||
// File-Logging IMMER (wenn enabled)
|
||||
writeToFile("DEBUG", tag, message)
|
||||
}
|
||||
|
||||
fun v(tag: String, message: String) {
|
||||
// Logcat nur in DEBUG builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.v(tag, message)
|
||||
}
|
||||
// File-Logging IMMER (wenn enabled)
|
||||
writeToFile("VERBOSE", tag, message)
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(tag, message)
|
||||
}
|
||||
// INFO logs IMMER zeigen (auch in Release) - wichtige Events
|
||||
Log.i(tag, message)
|
||||
// File-Logging IMMER (wenn enabled)
|
||||
writeToFile("INFO", tag, message)
|
||||
}
|
||||
|
||||
// Errors und Warnings IMMER zeigen (auch in Release)
|
||||
@@ -34,9 +126,11 @@ object Logger {
|
||||
} else {
|
||||
Log.e(tag, message)
|
||||
}
|
||||
writeToFile("ERROR", tag, message, throwable)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
Log.w(tag, message)
|
||||
writeToFile("WARN", tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user