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:
Inventory69
2025-12-22 00:49:24 +01:00
committed by GitHub
parent 86c5e62fd6
commit c55b64dab3
33 changed files with 4687 additions and 466 deletions

View File

@@ -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>,

View File

@@ -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
}

View File

@@ -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 -> {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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]
}
}

View File

@@ -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()
}
}

View File

@@ -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
)

View File

@@ -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

View File

@@ -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)
}
}