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.
1147 lines
45 KiB
Kotlin
1147 lines
45 KiB
Kotlin
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()
|
||
}
|
||
}
|