Files
simple-notes-sync/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
inventory69 dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

Removed:
- getBuildDate() function from build.gradle.kts
- BUILD_DATE buildConfigField
- Build date display in SettingsActivity

Sorry for the hour of work this caused - will be more careful about RB in the future.
2026-01-10 23:47:57 +01:00

1147 lines
45 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dev.dettmer.simplenotes
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
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 com.google.android.material.card.MaterialCardView
import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast
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 const val CONNECTION_TIMEOUT_MS = 3000
}
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
private lateinit var editTextServerUrl: EditText
private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat
private lateinit var switchMarkdownAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button
private lateinit var buttonManualMarkdownSync: Button
private lateinit var textViewServerStatus: TextView
private lateinit var textViewManualSyncInfo: TextView
// Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup
private lateinit var radioHttp: RadioButton
private lateinit var radioHttps: RadioButton
private lateinit var protocolHintText: TextView
// 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
// Debug Section UI
private lateinit var switchFileLogging: com.google.android.material.materialswitch.MaterialSwitch
private lateinit var buttonExportLogs: Button
private lateinit var buttonClearLogs: Button
// Backup Manager
private val backupManager by lazy { BackupManager(this) }
// Activity Result Launchers
private val createBackupLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { createBackup(it) }
}
private val restoreBackupLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
}
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
}
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
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
title = "Einstellungen"
}
findViews()
loadSettings()
setupListeners()
setupSyncIntervalPicker()
setupAboutSection()
setupDebugSection()
}
private fun findViews() {
textInputLayoutServerUrl = findViewById(R.id.textInputLayoutServerUrl)
editTextServerUrl = findViewById(R.id.editTextServerUrl)
editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync)
switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
// Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
radioHttp = findViewById(R.id.radioHttp)
radioHttps = findViewById(R.id.radioHttps)
protocolHintText = findViewById(R.id.protocolHintText)
// 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)
// Debug Section UI
switchFileLogging = findViewById(R.id.switchFileLogging)
buttonExportLogs = findViewById(R.id.buttonExportLogs)
buttonClearLogs = findViewById(R.id.buttonClearLogs)
}
private fun loadSettings() {
val savedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// Parse existing URL to extract protocol and host/path
if (savedUrl.isNotEmpty()) {
val (protocol, hostPath) = parseUrl(savedUrl)
// Set protocol radio button
when (protocol) {
"http" -> radioHttp.isChecked = true
"https" -> radioHttps.isChecked = true
else -> radioHttp.isChecked = true // Default to HTTP (most users have local servers)
}
// Set URL with protocol prefix in the text field
editTextServerUrl.setText("$protocol://$hostPath")
} else {
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
radioHttp.isChecked = true
editTextServerUrl.setText("http://")
}
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
// Load Markdown Auto-Sync (backward compatible)
val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
val markdownAutoSync = markdownExport && markdownAutoImport
switchMarkdownAutoSync.isChecked = markdownAutoSync
updateMarkdownButtonVisibility()
// Update hint text based on selected protocol
updateProtocolHint()
// Server Status prüfen
checkServerStatus()
}
/**
* Parse URL into protocol and host/path components
* @param url Full URL like "https://example.com:8080/webdav"
* @return Pair of (protocol, hostPath) like ("https", "example.com:8080/webdav")
*/
private fun parseUrl(url: String): Pair<String, String> {
return when {
url.startsWith("https://") -> "https" to url.removePrefix("https://")
url.startsWith("http://") -> "http" to url.removePrefix("http://")
else -> "http" to url // Default to HTTP if no protocol specified
}
}
/**
* Update the hint text below protocol selection based on selected protocol
*/
private fun updateProtocolHint() {
protocolHintText.text = if (radioHttp.isChecked) {
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
} else {
"HTTPS für sichere Verbindungen über das Internet"
}
}
/**
* Update protocol prefix in URL field when radio button changes
* Keeps the host/path part, only changes http:// <-> https://
*/
private fun updateProtocolInUrl() {
val currentText = editTextServerUrl.text.toString()
val newProtocol = if (radioHttp.isChecked) "http" else "https"
// Extract host/path without protocol
val hostPath = when {
currentText.startsWith("https://") -> currentText.removePrefix("https://")
currentText.startsWith("http://") -> currentText.removePrefix("http://")
else -> currentText
}
// Set new URL with correct protocol
editTextServerUrl.setText("$newProtocol://$hostPath")
// Move cursor to end
editTextServerUrl.setSelection(editTextServerUrl.text?.length ?: 0)
}
private fun setupListeners() {
// Protocol selection listener - update URL prefix when radio changes
protocolRadioGroup.setOnCheckedChangeListener { _, checkedId ->
updateProtocolInUrl()
updateProtocolHint()
}
buttonTestConnection.setOnClickListener {
saveSettings()
testConnection()
}
buttonSyncNow.setOnClickListener {
saveSettings()
syncNow()
}
buttonCreateBackup.setOnClickListener {
// Dateiname mit Timestamp
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(java.util.Date())
val filename = "simplenotes_backup_$timestamp.json"
createBackupLauncher.launch(filename)
}
buttonRestoreFromFile.setOnClickListener {
restoreBackupLauncher.launch(arrayOf("application/json"))
}
buttonRestoreFromServer.setOnClickListener {
saveSettings()
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
buttonManualMarkdownSync.setOnClickListener {
performManualMarkdownSync()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
}
switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
onMarkdownAutoSyncToggled(isChecked)
}
// Clear error when user starts typing again
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
textInputLayoutServerUrl.error = null
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
// Server Status Check bei Settings-Änderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
checkServerStatus()
}
}
}
/**
* 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
try {
val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE
textViewAppVersion.text = "Version $versionName ($versionCode)"
} 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)
}
}
/**
* Setup Debug section with file logging toggle and export functionality
*/
private fun setupDebugSection() {
// Load current file logging state
val fileLoggingEnabled = prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
switchFileLogging.isChecked = fileLoggingEnabled
// Update Logger state
Logger.setFileLoggingEnabled(fileLoggingEnabled)
// Toggle file logging
switchFileLogging.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, isChecked).apply()
Logger.setFileLoggingEnabled(isChecked)
if (isChecked) {
showToast("📝 Datei-Logging aktiviert")
Logger.i(TAG, "File logging enabled by user")
} else {
showToast("📝 Datei-Logging deaktiviert")
}
}
// Export logs button
buttonExportLogs.setOnClickListener {
exportAndShareLogs()
}
// Clear logs button
buttonClearLogs.setOnClickListener {
showClearLogsConfirmation()
}
}
/**
* Export logs and share via system share sheet
*/
private fun exportAndShareLogs() {
lifecycleScope.launch {
try {
val logFile = Logger.getLogFile(this@SettingsActivity)
if (logFile == null || !logFile.exists() || logFile.length() == 0L) {
showToast("📭 Keine Logs vorhanden")
return@launch
}
// Create share intent using FileProvider
val logUri = FileProvider.getUriForFile(
this@SettingsActivity,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, "SimpleNotes Sync Logs")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "Logs teilen via..."))
Logger.i(TAG, "Logs exported and shared")
} catch (e: Exception) {
Logger.e(TAG, "Failed to export logs", e)
showToast("❌ Fehler beim Exportieren: ${e.message}")
}
}
}
/**
* Show confirmation dialog before clearing logs
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Clear all log files
*/
private fun clearLogs() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
} else {
showToast("📭 Keine Logs zum Löschen")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
}
}
/**
* Opens URL in browser
*/
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() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
textInputLayoutServerUrl.error = errorMessage
return
}
}
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, fullUrl)
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
apply()
}
}
private fun testConnection() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
// 🔥 v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
textInputLayoutServerUrl.error = errorMessage
return
}
}
lifecycleScope.launch {
try {
showToast("Teste Verbindung...")
val syncService = WebDavSyncService(this@SettingsActivity)
val result = syncService.testConnection()
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
}
}
}
private fun syncNow() {
// 🔄 v1.3.1: Check if sync already running (Button wird deaktiviert)
if (!SyncStateManager.tryStartSync("settings")) {
return
}
// Disable button during sync
buttonSyncNow.isEnabled = false
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@SettingsActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert")
SyncStateManager.markCompleted()
return@launch
}
showToast("🔄 Synchronisiere...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar")
SyncStateManager.markError("Server nicht erreichbar")
checkServerStatus() // Server-Status aktualisieren
return@launch
}
val result = syncService.syncNotes()
if (result.isSuccess) {
if (result.hasConflicts) {
showToast("✅ Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
} else {
showToast("✅ Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
}
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
} else {
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
}
} catch (e: Exception) {
showToast("❌ Fehler: ${e.message}")
SyncStateManager.markError(e.message)
checkServerStatus() // ✅ Auch bei Exception aktualisieren
} finally {
// Re-enable button
buttonSyncNow.isEnabled = true
}
}
}
private fun checkServerStatus() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
textViewServerStatus.text = "❌ Nicht konfiguriert"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
return
}
textViewServerStatus.text = "🔍 Prüfe..."
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
lifecycleScope.launch {
val isReachable = withContext(Dispatchers.IO) {
try {
val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = CONNECTION_TIMEOUT_MS
val code = connection.responseCode
connection.disconnect()
code in 200..299 || code == 401 // 401 = Server da, Auth fehlt
} catch (e: Exception) {
Log.e(TAG, "Server check failed: ${e.message}")
false
}
}
if (isReachable) {
textViewServerStatus.text = "✅ Erreichbar"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
} else {
textViewServerStatus.text = "❌ Nicht erreichbar"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
}
}
}
private fun onAutoSyncToggled(enabled: Boolean) {
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
if (enabled) {
showToast("Auto-Sync aktiviert")
checkBatteryOptimization()
restartNetworkMonitor()
} else {
showToast("Auto-Sync deaktiviert")
restartNetworkMonitor()
}
}
private fun onMarkdownAutoSyncToggled(enabled: Boolean) {
if (enabled) {
// Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch {
try {
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(this@SettingsActivity)
val currentNoteCount = noteStorage.loadAllNotes().size
if (currentNoteCount > 0) {
// Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown Auto-Sync")
setMessage("Exportiere Notizen nach Markdown...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = currentNoteCount
progress = 0
setCancelable(false)
show()
}
try {
// Hole Server-Daten
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
progressDialog.dismiss()
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
switchMarkdownAutoSync.isChecked = false
return@launch
}
// Führe Initial-Export aus
val syncService = WebDavSyncService(this@SettingsActivity)
val exportedCount = syncService.exportAllNotesToMarkdown(
serverUrl = serverUrl,
username = username,
password = password,
onProgress = { current, total ->
runOnUiThread {
progressDialog.progress = current
progressDialog.setMessage("Exportiere $current/$total Notizen...")
}
}
)
progressDialog.dismiss()
// Speichere beide Einstellungen
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
// Erfolgs-Nachricht
showToast("$exportedCount Notizen nach Markdown exportiert")
} catch (e: Exception) {
progressDialog.dismiss()
showToast("❌ Export fehlgeschlagen: ${e.message}")
// Deaktiviere Toggle bei Fehler
switchMarkdownAutoSync.isChecked = false
return@launch
}
} else {
// Keine Notizen vorhanden - speichere Einstellungen direkt
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
showToast(
"Markdown Auto-Sync aktiviert - " +
"Notizen werden als .md-Dateien exportiert und importiert"
)
}
} catch (e: Exception) {
Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}")
showToast("Fehler: ${e.message}")
switchMarkdownAutoSync.isChecked = false
}
}
} else {
// Deaktivieren - Settings speichern
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
}
}
private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
val packageName = packageName
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
showBatteryOptimizationDialog()
}
}
private fun showBatteryOptimizationDialog() {
AlertDialog.Builder(this)
.setTitle("Hintergrund-Synchronisation")
.setMessage(
"Damit die App im Hintergrund synchronisieren kann, " +
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
"Bitte wähle 'Nicht optimieren' für Simple Notes."
)
.setPositiveButton("Einstellungen öffnen") { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton("Später") { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
private fun openBatteryOptimizationSettings() {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
// Fallback: Open general battery settings
try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent)
} catch (e2: Exception) {
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
showToast("Bitte Akku-Optimierung manuell deaktivieren")
}
}
}
private fun restartNetworkMonitor() {
try {
val app = application as SimpleNotesApplication
Log.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
app.networkMonitor.stopMonitoring()
app.networkMonitor.startMonitoring()
Log.d(TAG, "✅ NetworkMonitor restarted successfully")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to restart NetworkMonitor", e)
showToast("Fehler beim Neustart des NetworkMonitors")
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
saveSettings()
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onPause() {
super.onPause()
saveSettings()
}
// ========================================
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
// ========================================
/**
* Restore-Quelle (Lokale Datei oder WebDAV Server)
*/
private enum class RestoreSource {
LOCAL_FILE,
WEBDAV_SERVER
}
/**
* Erstellt Backup (Task #1.2.0-04)
*/
private fun createBackup(uri: Uri) {
lifecycleScope.launch {
try {
Logger.d(TAG, "📦 Creating backup...")
val result = backupManager.createBackup(uri)
if (result.success) {
showToast("${result.message}")
} else {
showErrorDialog("Backup fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
showErrorDialog("Backup fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Universeller Restore-Dialog für beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
*
* @param source Lokale Datei oder WebDAV Server
* @param fileUri URI der lokalen Datei (nur für LOCAL_FILE)
*/
private fun showRestoreDialog(source: RestoreSource, fileUri: Uri?) {
val sourceText = when (source) {
RestoreSource.LOCAL_FILE -> "Lokale Datei"
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
}
// Custom View mit Radio Buttons
val radioGroup = android.widget.RadioGroup(this).apply {
orientation = android.widget.RadioGroup.VERTICAL
setPadding(50, 20, 50, 20)
}
// Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
id = android.view.View.generateViewId()
isChecked = true
setPadding(10, 10, 10, 10)
}
val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
radioGroup.addView(radioMerge)
radioGroup.addView(radioReplace)
radioGroup.addView(radioOverwrite)
// Hauptlayout
val mainLayout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(50, 30, 50, 30)
}
// Info Text
val infoText = android.widget.TextView(this).apply {
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
textSize = 16f
setPadding(0, 0, 0, 20)
}
// Hinweis Text
val hintText = android.widget.TextView(this).apply {
text = "\n Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
textSize = 14f
setTypeface(null, android.graphics.Typeface.ITALIC)
setPadding(0, 20, 0, 0)
}
mainLayout.addView(infoText)
mainLayout.addView(radioGroup)
mainLayout.addView(hintText)
// Dialog erstellen
AlertDialog.Builder(this)
.setTitle("⚠️ Backup wiederherstellen?")
.setView(mainLayout)
.setPositiveButton("Wiederherstellen") { _, _ ->
val selectedMode = when (radioGroup.checkedRadioButtonId) {
radioReplace.id -> RestoreMode.REPLACE
radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
else -> RestoreMode.MERGE
}
when (source) {
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
}
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Führt Restore aus lokaler Datei durch (Task #1.2.0-05)
*/
private fun performRestoreFromFile(uri: Uri, mode: RestoreMode) {
lifecycleScope.launch {
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
setMessage("Wiederherstellen...")
setCancelable(false)
show()
}
try {
Logger.d(TAG, "📥 Restoring from file: $uri (mode: $mode)")
val result = backupManager.restoreBackup(uri, mode)
progressDialog.dismiss()
if (result.success) {
val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen"
showToast("$message")
// Refresh MainActivity's note list
setResult(RESULT_OK)
broadcastNotesChanged(result.importedNotes)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
progressDialog.dismiss()
Logger.e(TAG, "Failed to restore from file", e)
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Server-Restore mit Restore-Modi (v1.3.0)
*/
private fun performRestoreFromServer(mode: RestoreMode) {
lifecycleScope.launch {
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
setMessage("Wiederherstellen vom Server...")
setCancelable(false)
show()
}
try {
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
// Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
}
// Server-Restore durchführen
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
webdavService.restoreFromServer(mode) // ✅ Pass mode parameter
}
progressDialog.dismiss()
if (result.isSuccess) {
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK)
broadcastNotesChanged(result.restoredCount)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
progressDialog.dismiss()
Logger.e(TAG, "Failed to restore from server", e)
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Sendet Broadcast dass Notizen geändert wurden
*/
private fun broadcastNotesChanged(count: Int = 0) {
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
intent.putExtra("success", true)
intent.putExtra("syncedCount", count)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
/**
* Updates visibility of manual sync button based on Auto-Sync toggle state
*/
private fun updateMarkdownButtonVisibility() {
val autoSyncEnabled = switchMarkdownAutoSync.isChecked
val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE
textViewManualSyncInfo.visibility = visibility
buttonManualMarkdownSync.visibility = visibility
}
/**
* Performs manual Markdown sync (Export + Import)
* Called when manual sync button is clicked
*/
private fun performManualMarkdownSync() {
lifecycleScope.launch {
var progressDialog: ProgressDialog? = null
try {
// Validierung
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "")
val username = prefs.getString(Constants.KEY_USERNAME, "")
val password = prefs.getString(Constants.KEY_PASSWORD, "")
if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) {
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
return@launch
}
// Progress-Dialog
progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Sync")
setMessage("Synchronisiere Markdown-Dateien...")
setCancelable(false)
show()
}
// Sync ausführen
val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity)
val result = syncService.manualMarkdownSync()
progressDialog.dismiss()
// Erfolgs-Nachricht
val message = "✅ Sync abgeschlossen\n" +
"📤 ${result.exportedCount} exportiert\n" +
"📥 ${result.importedCount} importiert"
showToast(message)
Logger.d(
"SettingsActivity",
"Manual markdown sync: exported=${result.exportedCount}, " +
"imported=${result.importedCount}"
)
} catch (e: Exception) {
progressDialog?.dismiss()
showToast("❌ Sync fehlgeschlagen: ${e.message}")
Logger.e("SettingsActivity", "Manual markdown sync failed", e)
}
}
}
/**
* Zeigt Error-Dialog an
*/
private fun showErrorDialog(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
}