diff --git a/CHANGELOG.md b/CHANGELOG.md index 50acf21..d84fefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.3.2] - 2026-01-10 + +### Changed +- **🧹 Code-Qualität: "Clean Slate" Release** + - Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans) + - Unused Imports und Members entfernt + - Magic Numbers durch benannte Konstanten ersetzt + - SwallowedExceptions mit Logger.w() versehen + - MaxLineLength-Verstöße reformatiert + - ConstructorParameterNaming (snake_case → camelCase mit @SerializedName) + - Custom Exceptions: SyncException.kt und ValidationException.kt erstellt + +### Added +- **📝 F-Droid Privacy Notice** + - Datenschutz-Hinweis für die Datei-Logging-Funktion + - Erklärt dass Logs nur lokal gespeichert werden + - Erfüllt F-Droid Opt-in Consent-Anforderungen + +### Technical Improvements +- **⚡ Neue Konstanten für bessere Wartbarkeit** + - `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity) + - `CONNECTION_TIMEOUT_MS` (SettingsActivity) + - `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService) + - `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper) + - RFC 1918 IP-Range Konstanten (UrlValidator) + - `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions) + +- **🔒 @Suppress Annotations für legitime Patterns** + - ReturnCount: Frühe Returns für Validierung sind idiomatisch + - LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert + +### Notes +- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant +- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen + +--- + ## [1.3.1] - 2026-01-08 ### Fixed diff --git a/README.en.md b/README.en.md index 7e7c69d..013d86e 100644 --- a/README.en.md +++ b/README.en.md @@ -85,7 +85,7 @@ cd android ./gradlew assembleStandardRelease ``` -➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md) +➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment) --- @@ -101,4 +101,4 @@ MIT License - see [LICENSE](LICENSE) --- -**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 +**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3 diff --git a/README.md b/README.md index b974144..30c5168 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ cd android ./gradlew assembleStandardRelease ``` -➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md) +➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment) --- @@ -104,4 +104,4 @@ MIT License - siehe [LICENSE](LICENSE) --- -**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 +**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4a2a474..f33fbfc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 9 // 🚀 v1.3.1: Sync-Performance & Debug-Logging - versionName = "1.3.1" // 🚀 v1.3.1: Skip unchanged MD-Files, Sync-Mutex, Debug-Logging UI + versionCode = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate" + versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index e14a950..16d249a 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -76,6 +76,8 @@ class MainActivity : AppCompatActivity() { 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" + private const val SYNC_COMPLETED_DELAY_MS = 1500L + private const val ERROR_DISPLAY_DELAY_MS = 3000L } /** @@ -152,7 +154,7 @@ class MainActivity : AppCompatActivity() { // Show completed briefly, then hide syncStatusText.text = status.message ?: getString(R.string.sync_status_completed) lifecycleScope.launch { - kotlinx.coroutines.delay(1500) + kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS) syncStatusBanner.visibility = View.GONE SyncStateManager.reset() } @@ -164,7 +166,7 @@ class MainActivity : AppCompatActivity() { // Show error briefly, then hide syncStatusText.text = status.message ?: getString(R.string.sync_status_error) lifecycleScope.launch { - kotlinx.coroutines.delay(3000) + kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS) syncStatusBanner.visibility = View.GONE SyncStateManager.reset() } @@ -518,16 +520,28 @@ class MainActivity : AppCompatActivity() { val success = webdavService.deleteNoteFromServer(note.id) if (success) { runOnUiThread { - Toast.makeText(this@MainActivity, "Vom Server gelöscht", Toast.LENGTH_SHORT).show() + Toast.makeText( + this@MainActivity, + "Vom Server gelöscht", + Toast.LENGTH_SHORT + ).show() } } else { runOnUiThread { - Toast.makeText(this@MainActivity, "Server-Löschung fehlgeschlagen", Toast.LENGTH_LONG).show() + Toast.makeText( + this@MainActivity, + "Server-Löschung fehlgeschlagen", + Toast.LENGTH_LONG + ).show() } } } catch (e: Exception) { runOnUiThread { - Toast.makeText(this@MainActivity, "Server-Fehler: ${e.message}", Toast.LENGTH_LONG).show() + Toast.makeText( + this@MainActivity, + "Server-Fehler: ${e.message}", + Toast.LENGTH_LONG + ).show() } } } @@ -689,4 +703,4 @@ class MainActivity : AppCompatActivity() { } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt index a706806..0b8d6d6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -25,8 +25,6 @@ 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 com.google.android.material.switchmaterial.SwitchMaterial -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import dev.dettmer.simplenotes.backup.BackupManager @@ -39,7 +37,6 @@ 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 @@ -52,6 +49,7 @@ class SettingsActivity : AppCompatActivity() { 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 @@ -325,7 +323,10 @@ class SettingsActivity : AppCompatActivity() { */ private fun setupSyncIntervalPicker() { // Load current interval from preferences - val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) + 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) { @@ -654,8 +655,8 @@ class SettingsActivity : AppCompatActivity() { try { val url = URL(serverUrl) val connection = url.openConnection() as HttpURLConnection - connection.connectTimeout = 3000 - connection.readTimeout = 3000 + 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 @@ -764,7 +765,10 @@ class SettingsActivity : AppCompatActivity() { .apply() updateMarkdownButtonVisibility() - showToast("Markdown Auto-Sync aktiviert - Notizen werden als .md-Dateien exportiert und importiert") + showToast( + "Markdown Auto-Sync aktiviert - " + + "Notizen werden als .md-Dateien exportiert und importiert" + ) } } catch (e: Exception) { @@ -818,11 +822,13 @@ class SettingsActivity : AppCompatActivity() { 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") } } @@ -841,49 +847,6 @@ class SettingsActivity : AppCompatActivity() { } } - 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 -> { @@ -946,7 +909,6 @@ class SettingsActivity : AppCompatActivity() { } // Custom View mit Radio Buttons - val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null) val radioGroup = android.widget.RadioGroup(this).apply { orientation = android.widget.RadioGroup.VERTICAL setPadding(50, 20, 50, 20) @@ -1039,12 +1001,12 @@ class SettingsActivity : AppCompatActivity() { progressDialog.dismiss() if (result.success) { - val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen" + val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen" showToast("✅ $message") // Refresh MainActivity's note list setResult(RESULT_OK) - broadcastNotesChanged(result.imported_notes) + broadcastNotesChanged(result.importedNotes) } else { showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler") } @@ -1153,10 +1115,16 @@ class SettingsActivity : AppCompatActivity() { progressDialog.dismiss() // Erfolgs-Nachricht - val message = "✅ Sync abgeschlossen\n📤 ${result.exportedCount} exportiert\n📥 ${result.importedCount} importiert" + 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}") + Logger.d( + "SettingsActivity", + "Manual markdown sync: exported=${result.exportedCount}, " + + "imported=${result.importedCount}" + ) } catch (e: Exception) { progressDialog?.dismiss() diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt index 992cceb..e741261 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt @@ -49,10 +49,10 @@ class BackupManager(private val context: Context) { Logger.d(TAG, " Found ${allNotes.size} notes to backup") val backupData = BackupData( - backup_version = BACKUP_VERSION, - created_at = System.currentTimeMillis(), - notes_count = allNotes.size, - app_version = BuildConfig.VERSION_NAME, + backupVersion = BACKUP_VERSION, + createdAt = System.currentTimeMillis(), + notesCount = allNotes.size, + appVersion = BuildConfig.VERSION_NAME, notes = allNotes ) @@ -65,7 +65,7 @@ class BackupManager(private val context: Context) { BackupResult( success = true, - notes_count = allNotes.size, + notesCount = allNotes.size, message = "Backup erstellt: ${allNotes.size} Notizen" ) @@ -99,10 +99,10 @@ class BackupManager(private val context: Context) { val allNotes = storage.loadAllNotes() val backupData = BackupData( - backup_version = BACKUP_VERSION, - created_at = System.currentTimeMillis(), - notes_count = allNotes.size, - app_version = BuildConfig.VERSION_NAME, + backupVersion = BACKUP_VERSION, + createdAt = System.currentTimeMillis(), + notesCount = allNotes.size, + appVersion = BuildConfig.VERSION_NAME, notes = allNotes ) @@ -149,7 +149,7 @@ class BackupManager(private val context: Context) { } val backupData = gson.fromJson(jsonString, BackupData::class.java) - Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}") + Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}") // 3. Auto-Backup erstellen (Sicherheitsnetz) val autoBackupUri = createAutoBackup() @@ -164,7 +164,7 @@ class BackupManager(private val context: Context) { RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes) } - Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped") + Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped") result } catch (e: Exception) { @@ -184,10 +184,11 @@ class BackupManager(private val context: Context) { val backupData = gson.fromJson(jsonString, BackupData::class.java) // Version kompatibel? - if (backupData.backup_version > BACKUP_VERSION) { + if (backupData.backupVersion > BACKUP_VERSION) { return ValidationResult( isValid = false, - errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)" + errorMessage = "Backup-Version nicht unterstützt " + + "(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)" ) } @@ -238,8 +239,8 @@ class BackupManager(private val context: Context) { return RestoreResult( success = true, - imported_notes = newNotes.size, - skipped_notes = skippedNotes, + importedNotes = newNotes.size, + skippedNotes = skippedNotes, message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen" ) } @@ -259,8 +260,8 @@ class BackupManager(private val context: Context) { return RestoreResult( success = true, - imported_notes = backupNotes.size, - skipped_notes = 0, + importedNotes = backupNotes.size, + skippedNotes = 0, message = "Alle Notizen ersetzt: ${backupNotes.size} importiert" ) } @@ -283,9 +284,9 @@ class BackupManager(private val context: Context) { return RestoreResult( success = true, - imported_notes = newNotes.size, - skipped_notes = 0, - overwritten_notes = overwrittenNotes.size, + importedNotes = newNotes.size, + skippedNotes = 0, + overwrittenNotes = overwrittenNotes.size, message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben" ) } @@ -312,12 +313,17 @@ class BackupManager(private val context: Context) { /** * Backup-Daten Struktur (JSON) + * NOTE: Property names use @SerializedName for JSON compatibility with snake_case */ data class BackupData( - val backup_version: Int, - val created_at: Long, - val notes_count: Int, - val app_version: String, + @com.google.gson.annotations.SerializedName("backup_version") + val backupVersion: Int, + @com.google.gson.annotations.SerializedName("created_at") + val createdAt: Long, + @com.google.gson.annotations.SerializedName("notes_count") + val notesCount: Int, + @com.google.gson.annotations.SerializedName("app_version") + val appVersion: String, val notes: List ) @@ -335,7 +341,7 @@ enum class RestoreMode { */ data class BackupResult( val success: Boolean, - val notes_count: Int = 0, + val notesCount: Int = 0, val message: String? = null, val error: String? = null ) @@ -345,9 +351,9 @@ data class BackupResult( */ data class RestoreResult( val success: Boolean, - val imported_notes: Int = 0, - val skipped_notes: Int = 0, - val overwritten_notes: Int = 0, + val importedNotes: Int = 0, + val skippedNotes: Int = 0, + val overwrittenNotes: Int = 0, val message: String? = null, val error: String? = null ) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt index d120fce..c54137c 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt @@ -1,5 +1,6 @@ package dev.dettmer.simplenotes.models +import dev.dettmer.simplenotes.utils.Logger import org.json.JSONArray import org.json.JSONObject @@ -49,6 +50,8 @@ data class DeletionTracker( } companion object { + private const val TAG = "DeletionTracker" + fun fromJson(json: String): DeletionTracker? { return try { val jsonObject = JSONObject(json) @@ -70,6 +73,7 @@ data class DeletionTracker( DeletionTracker(version, deletedNotes) } catch (e: Exception) { + Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}") null } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt index 0098eb7..a43b5a6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt @@ -1,5 +1,6 @@ package dev.dettmer.simplenotes.models +import dev.dettmer.simplenotes.utils.Logger import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -49,11 +50,14 @@ $content } companion object { + private const val TAG = "Note" + fun fromJson(json: String): Note? { return try { val gson = com.google.gson.Gson() gson.fromJson(json, Note::class.java) } catch (e: Exception) { + Logger.w(TAG, "Failed to parse JSON: ${e.message}") null } } @@ -102,6 +106,7 @@ $content syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert ) } catch (e: Exception) { + Logger.w(TAG, "Failed to parse Markdown: ${e.message}") null } } @@ -126,6 +131,7 @@ $content sdf.timeZone = TimeZone.getTimeZone("UTC") sdf.parse(dateString)?.time ?: System.currentTimeMillis() } catch (e: Exception) { + Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}") System.currentTimeMillis() // Fallback } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt index 0327949..2f3b54e 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt @@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) { 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") + 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") @@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) { connectivityManager.unregisterNetworkCallback(networkCallback) Logger.d(TAG, "✅ WiFi monitoring stopped") } catch (e: Exception) { - // Already unregistered + Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}") } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt index fd3345a..4641312 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -117,7 +117,11 @@ class SyncWorker( if (BuildConfig.DEBUG) { Logger.d(TAG, "📍 Step 4: Processing result") - Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}") + Logger.d( + TAG, + "📦 Sync result: success=${result.isSuccess}, " + + "count=${result.syncedCount}, error=${result.errorMessage}" + ) } if (result.isSuccess) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index a4e34ed..8560ff5 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -12,6 +12,7 @@ import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger +import dev.dettmer.simplenotes.utils.SyncException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext @@ -20,7 +21,6 @@ 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 java.net.URL import java.util.Date @@ -38,6 +38,9 @@ class WebDavSyncService(private val context: Context) { companion object { private const val TAG = "WebDavSyncService" + private const val SOCKET_TIMEOUT_MS = 2000 + private const val MAX_FILENAME_LENGTH = 200 + private const val ETAG_PREVIEW_LENGTH = 8 // 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern private val syncMutex = Mutex() @@ -101,6 +104,7 @@ class WebDavSyncService(private val context: Context) { /** * Findet WiFi Interface IP-Adresse (um VPN zu umgehen) */ + @Suppress("ReturnCount") // Early returns for network validation checks private fun getWiFiInetAddressInternal(): InetAddress? { try { Logger.d(TAG, "🔍 getWiFiInetAddress() called") @@ -145,7 +149,11 @@ class WebDavSyncService(private val context: Context) { while (addresses.hasMoreElements()) { val addr = addresses.nextElement() - Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}") + 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) { @@ -362,6 +370,7 @@ class WebDavSyncService(private val context: Context) { * 3. If changed → server has updates * 4. If unchanged → skip sync */ + @Suppress("ReturnCount") // Early returns for conditional checks private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean { return try { val startTime = System.currentTimeMillis() @@ -413,7 +422,11 @@ class WebDavSyncService(private val context: Context) { resource.modified?.time?.let { val hasNewer = it > lastSyncTime if (hasNewer) { - Logger.d(TAG, " 📄 ${resource.name}: modified=${resource.modified}, lastSync=$lastSyncTime") + Logger.d( + TAG, + " 📄 ${resource.name}: modified=${resource.modified}, " + + "lastSync=$lastSyncTime" + ) } hasNewer } ?: false @@ -524,10 +537,10 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, "🔍 Checking server reachability: $host:$port") - // Socket-Check mit 2s Timeout + // Socket-Check mit Timeout // Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway) val socket = Socket() - socket.connect(InetSocketAddress(host, port), 2000) + socket.connect(InetSocketAddress(host, port), SOCKET_TIMEOUT_MS) socket.close() Logger.d(TAG, "✅ Server is reachable") @@ -669,7 +682,11 @@ class WebDavSyncService(private val context: Context) { ) syncedCount += downloadResult.downloadedCount conflictCount += downloadResult.conflictCount - Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}") + Logger.d( + TAG, + "✅ Downloaded: ${downloadResult.downloadedCount} notes, " + + "Conflicts: ${downloadResult.conflictCount}" + ) } catch (e: Exception) { Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e) e.printStackTrace() @@ -790,7 +807,7 @@ class WebDavSyncService(private val context: Context) { val newETag = uploadedResource?.etag if (newETag != null) { prefs.edit().putString("etag_json_${note.id}", newETag).apply() - Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(8)}") + Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}") } else { // Fallback: invalidate if server doesn't provide E-Tag prefs.edit().remove("etag_json_${note.id}").apply() @@ -814,6 +831,7 @@ class WebDavSyncService(private val context: Context) { } } } catch (e: Exception) { + Logger.w(TAG, "Upload failed for note ${note.id}, marking as pending: ${e.message}") // Mark as pending for retry val updatedNote = note.copy(syncStatus = SyncStatus.PENDING) storage.saveNote(updatedNote) @@ -862,7 +880,7 @@ class WebDavSyncService(private val context: Context) { return title .replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen .replace(Regex("\\s+"), " ") // Normalisiere Whitespace - .take(200) // Max 200 Zeichen (Reserve für .md) + .take(MAX_FILENAME_LENGTH) // Max Zeichen (Reserve für .md) .trim('_', ' ') // Trim Underscores/Spaces } @@ -998,9 +1016,13 @@ class WebDavSyncService(private val context: Context) { val serverModified = resource.modified?.time ?: 0L // 🐛 DEBUG: Log every file check to diagnose performance - val serverETagPreview = serverETag?.take(8) ?: "null" - val cachedETagPreview = cachedETag?.take(8) ?: "null" - Logger.d(TAG, " 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview modified=$serverModified lastSync=$lastSyncTime") + val serverETagPreview = serverETag?.take(ETAG_PREVIEW_LENGTH) ?: "null" + val cachedETagPreview = cachedETag?.take(ETAG_PREVIEW_LENGTH) ?: "null" + Logger.d( + TAG, + " 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview " + + "modified=$serverModified lastSync=$lastSyncTime" + ) // PRIMARY: Timestamp check (works on first sync!) // Same logic as Markdown sync - skip if not modified since last sync @@ -1101,7 +1123,11 @@ class WebDavSyncService(private val context: Context) { } } } - Logger.d(TAG, " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), $skippedUnchanged skipped (unchanged)") + Logger.d( + TAG, + " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), " + + "$skippedUnchanged skipped (unchanged)" + ) } else { Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") } @@ -1306,7 +1332,11 @@ class WebDavSyncService(private val context: Context) { // 🆕 v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite // 🆕 v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data - Logger.d(TAG, "📡 Calling downloadRemoteNotes() - includeRootFallback: true, forceOverwrite: $forceOverwrite") + Logger.d( + TAG, + "📡 Calling downloadRemoteNotes() - " + + "includeRootFallback: true, forceOverwrite: $forceOverwrite" + ) val emptyTracker = DeletionTracker() // Fresh empty tracker after clear val result = downloadRemoteNotes( sardine = sardine, @@ -1509,15 +1539,31 @@ class WebDavSyncService(private val context: Context) { Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null") continue } - Logger.d(TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, updatedAt=${Date(mdNote.updatedAt)}") + Logger.d( + TAG, + " Parsed: id=${mdNote.id}, title=${mdNote.title}, " + + "updatedAt=${Date(mdNote.updatedAt)}" + ) val localNote = storage.loadNote(mdNote.id) - Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}") + Logger.d( + TAG, + " Local note: " + if (localNote == null) { + "NOT FOUND" + } else { + "exists, updatedAt=${Date(localNote.updatedAt)}, " + + "syncStatus=${localNote.syncStatus}" + } + ) // ⚡ v1.3.1: Content-basierte Erkennung // Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde! // Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian) - Logger.d(TAG, " Comparison: mdUpdatedAt=${mdNote.updatedAt}, localUpdated=${localNote?.updatedAt ?: 0L}") + Logger.d( + TAG, + " Comparison: mdUpdatedAt=${mdNote.updatedAt}, " + + "localUpdated=${localNote?.updatedAt ?: 0L}" + ) // Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich? val contentChanged = localNote != null && ( @@ -1538,10 +1584,16 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}") } // ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich - localNote.syncStatus == SyncStatus.SYNCED && !contentChanged && localNote.updatedAt >= mdNote.updatedAt -> { + localNote.syncStatus == SyncStatus.SYNCED && + !contentChanged && + localNote.updatedAt >= mdNote.updatedAt -> { // Inhalt identisch UND Timestamps passen → Skip skippedCount++ - Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: content identical (local=${localNote.updatedAt}, md=${mdNote.updatedAt})") + Logger.d( + TAG, + " ⏭️ Skipped ${mdNote.title}: content identical " + + "(local=${localNote.updatedAt}, md=${mdNote.updatedAt})" + ) } // ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren! contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> { @@ -1571,7 +1623,11 @@ class WebDavSyncService(private val context: Context) { else -> { // Local has pending changes but MD is older - keep local skippedCount++ - Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer or pending (local=${localNote.updatedAt}, md=${mdNote.updatedAt})") + Logger.d( + TAG, + " ⏭️ Skipped ${mdNote.title}: local is newer or pending " + + "(local=${localNote.updatedAt}, md=${mdNote.updatedAt})" + ) } } } catch (e: Exception) { @@ -1719,14 +1775,16 @@ class WebDavSyncService(private val context: Context) { */ suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) { return@withContext try { - val sardine = getOrCreateSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden") - val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert") + val sardine = getOrCreateSardine() + ?: throw SyncException("Sardine client konnte nicht erstellt werden") + val serverUrl = getServerUrl() + ?: throw SyncException("Server-URL nicht konfiguriert") val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { - throw Exception("WebDAV-Server nicht vollständig konfiguriert") + throw SyncException("WebDAV-Server nicht vollständig konfiguriert") } Logger.d(TAG, "🔄 Manual Markdown Sync START") diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt index bc61138..56e9eec 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt @@ -28,6 +28,7 @@ class WifiSyncReceiver : BroadcastReceiver() { } } + @Suppress("ReturnCount") // Early returns for WiFi validation checks private fun isConnectedToHomeWifi(context: Context): Boolean { val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt index 747388e..84139b9 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt @@ -7,6 +7,9 @@ import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit +private const val DAYS_THRESHOLD = 7L +private const val TRUNCATE_SUFFIX_LENGTH = 3 + // Toast Extensions fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) { Toast.makeText(this, message, duration).show() @@ -27,7 +30,7 @@ fun Long.toReadableTime(): String { val hours = TimeUnit.MILLISECONDS.toHours(diff) "Vor $hours Std" } - diff < TimeUnit.DAYS.toMillis(7) -> { + diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> { val days = TimeUnit.MILLISECONDS.toDays(diff) "Vor $days Tagen" } @@ -41,7 +44,7 @@ fun Long.toReadableTime(): String { // Truncate long strings fun String.truncate(maxLength: Int): String { return if (length > maxLength) { - substring(0, maxLength - 3) + "..." + substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..." } else { this } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt index 382d40d..d5e3b3e 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt @@ -5,7 +5,6 @@ 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.* @@ -15,11 +14,12 @@ import java.util.* */ object Logger { + private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge + private var fileLoggingEnabled = false private var logFile: File? = null private var appContext: Context? = null private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) - private val maxLogEntries = 500 // Nur letzte 500 Einträge /** * Setzt den File-Logging Status (für UI Toggle) @@ -139,13 +139,13 @@ object Logger { } /** - * Begrenzt Log-Datei auf maxLogEntries + * Begrenzt Log-Datei auf MAX_LOG_ENTRIES */ private fun trimLogFile() { try { val lines = logFile?.readLines() ?: return - if (lines.size > maxLogEntries) { - val trimmed = lines.takeLast(maxLogEntries) + if (lines.size > MAX_LOG_ENTRIES) { + val trimmed = lines.takeLast(MAX_LOG_ENTRIES) logFile?.writeText(trimmed.joinToString("\n") + "\n") } } catch (e: Exception) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt index 075d9e6..37eee56 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt @@ -20,6 +20,7 @@ object NotificationHelper { private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status" private const val NOTIFICATION_ID = 1001 private const val SYNC_NOTIFICATION_ID = 2 + private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L /** * Erstellt Notification Channel (Android 8.0+) @@ -286,7 +287,7 @@ object NotificationHelper { Handler(Looper.getMainLooper()).postDelayed({ manager.cancel(SYNC_NOTIFICATION_ID) Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout") - }, 30_000) + }, AUTO_CANCEL_TIMEOUT_MS) } /** diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/SyncException.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/SyncException.kt new file mode 100644 index 0000000..7f4fa9b --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/SyncException.kt @@ -0,0 +1,21 @@ +package dev.dettmer.simplenotes.utils + +/** + * Exception für Sync-spezifische Fehler + * + * Verwendet anstelle von generischen Exceptions für bessere + * Fehlerbehandlung und klarere Fehlermeldungen. + */ +class SyncException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** + * Exception für Validierungsfehler + * + * Verwendet für ungültige Eingaben oder Konfigurationsfehler. + */ +class ValidationException( + message: String +) : IllegalArgumentException(message) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt index b9c1f54..4c7410b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/UrlValidator.kt @@ -8,6 +8,16 @@ import java.net.URL */ object UrlValidator { + // RFC 1918 Private IP Ranges + private const val PRIVATE_CLASS_A_FIRST_OCTET = 10 + private const val PRIVATE_CLASS_B_FIRST_OCTET = 172 + private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16 + private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31 + private const val PRIVATE_CLASS_C_FIRST_OCTET = 192 + private const val PRIVATE_CLASS_C_SECOND_OCTET = 168 + private const val LOCALHOST_FIRST_OCTET = 127 + private const val OCTET_MAX_VALUE = 255 + /** * Prüft ob eine URL eine lokale/private Adresse ist * Erlaubt: @@ -17,6 +27,7 @@ object UrlValidator { * - 127.x.x.x (Localhost) * - .local domains (mDNS/Bonjour) */ + @Suppress("ReturnCount") // Early returns for validation checks are clearer fun isLocalUrl(url: String): Boolean { return try { val parsedUrl = URL(url) @@ -40,25 +51,29 @@ object UrlValidator { val octets = match.groupValues.drop(1).map { it.toInt() } // Validate octets are in range 0-255 - if (octets.any { it > 255 }) { + if (octets.any { it > OCTET_MAX_VALUE }) { return false } - val (o1, o2, o3, o4) = octets + // Extract octets individually (destructuring with 4 elements triggers detekt warning) + val o1 = octets[0] + val o2 = octets[1] // Check RFC 1918 private IP ranges return when { // 10.0.0.0/8 (10.0.0.0 - 10.255.255.255) - o1 == 10 -> true + o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255) - o1 == 172 && o2 in 16..31 -> true + o1 == PRIVATE_CLASS_B_FIRST_OCTET && + o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true // 192.168.0.0/16 (192.168.0.0 - 192.168.255.255) - o1 == 192 && o2 == 168 -> true + o1 == PRIVATE_CLASS_C_FIRST_OCTET && + o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true // 127.0.0.0/8 (Localhost) - o1 == 127 -> true + o1 == LOCALHOST_FIRST_OCTET -> true else -> false } @@ -67,7 +82,7 @@ object UrlValidator { // Not a recognized local address false } catch (e: Exception) { - // Invalid URL format + Logger.w("UrlValidator", "Failed to parse URL: ${e.message}") false } } diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 0b884fd..77ca364 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -818,6 +818,17 @@ + + +