From b70bc4d8f672d76c723097d60252ddabcdc6fc9d Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 26 Jan 2026 21:19:46 +0100 Subject: [PATCH 1/8] debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support --- .github/workflows/build-debug-apk.yml | 87 ++++++ .github/workflows/build-production-apk.yml | 13 + .gitignore | 1 + CHANGELOG.de.md | 36 +++ CHANGELOG.md | 36 +++ README.de.md | 3 +- README.md | 16 +- android/app/build.gradle.kts | 12 +- .../simplenotes/backup/BackupManager.kt | 65 ++++- .../simplenotes/backup/EncryptionManager.kt | 172 ++++++++++++ .../dev/dettmer/simplenotes/models/Note.kt | 28 ++ .../simplenotes/storage/NotesStorage.kt | 20 ++ .../simplenotes/sync/WebDavSyncService.kt | 30 ++- .../simplenotes/ui/editor/NoteEditorScreen.kt | 11 +- .../ui/main/ComposeMainActivity.kt | 3 + .../dettmer/simplenotes/ui/main/MainScreen.kt | 68 +++-- .../simplenotes/ui/main/MainViewModel.kt | 42 ++- .../ui/main/components/NoteCard.kt | 2 +- .../ui/main/components/NoteCardCompact.kt | 246 +++++++++++++++++ .../ui/main/components/NoteCardGrid.kt | 250 ++++++++++++++++++ .../ui/main/components/NotesList.kt | 3 + .../ui/main/components/NotesStaggeredGrid.kt | 70 +++++ .../ui/settings/SettingsNavigation.kt | 9 + .../simplenotes/ui/settings/SettingsRoute.kt | 1 + .../ui/settings/SettingsViewModel.kt | 185 ++++++++++++- .../components/BackupPasswordDialog.kt | 180 +++++++++++++ .../settings/screens/BackupSettingsScreen.kt | 81 +++++- .../settings/screens/DebugSettingsScreen.kt | 7 +- .../settings/screens/DisplaySettingsScreen.kt | 74 ++++++ .../settings/screens/ServerSettingsScreen.kt | 10 + .../ui/settings/screens/SettingsMainScreen.kt | 17 ++ .../ui/settings/screens/SyncSettingsScreen.kt | 13 + .../dettmer/simplenotes/utils/Constants.kt | 10 + .../app/src/main/res/values-de/strings.xml | 37 ++- android/app/src/main/res/values/strings.xml | 36 ++- .../main/res/xml/network_security_config.xml | 2 + .../simplenotes/models/NoteSizeTest.kt | 172 ++++++++++++ .../utils/EncryptionManagerTest.kt | 205 ++++++++++++++ docs/DEBUG_APK.md | 116 ++++++++ docs/SELF_SIGNED_SSL.md | 166 ++++++++++++ .../metadata/android/de-DE/changelogs/17.txt | 8 + .../metadata/android/en-US/changelogs/17.txt | 8 + 42 files changed, 2491 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/build-debug-apk.yml create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/backup/EncryptionManager.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesStaggeredGrid.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/BackupPasswordDialog.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DisplaySettingsScreen.kt create mode 100644 android/app/src/test/java/dev/dettmer/simplenotes/models/NoteSizeTest.kt create mode 100644 android/app/src/test/java/dev/dettmer/simplenotes/utils/EncryptionManagerTest.kt create mode 100644 docs/DEBUG_APK.md create mode 100644 docs/SELF_SIGNED_SSL.md create mode 100644 fastlane/metadata/android/de-DE/changelogs/17.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/17.txt diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml new file mode 100644 index 0000000..dd57c36 --- /dev/null +++ b/.github/workflows/build-debug-apk.yml @@ -0,0 +1,87 @@ +name: Build Debug APK + +on: + push: + branches: + - 'debug/**' + - 'fix/**' + - 'feature/**' + workflow_dispatch: # Manueller Trigger möglich + +jobs: + build-debug: + name: Build Debug APK + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Extract version info + run: | + VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/') + VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/') + BRANCH_NAME=${GITHUB_REF#refs/heads/} + COMMIT_SHA=$(git rev-parse --short HEAD) + + echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV + echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV + echo "BUILD_TIME=$(date +'%Y-%m-%d_%H-%M-%S')" >> $GITHUB_ENV + + - name: Build Debug APK (Standard + F-Droid) + run: | + cd android + ./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace + + - name: Prepare Debug APK artifacts + run: | + mkdir -p debug-apks + + cp android/app/build/outputs/apk/standard/debug/app-standard-debug.apk \ + debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-standard-debug.apk + + cp android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk \ + debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-fdroid-debug.apk + + echo "✅ Debug APK Files ready:" + ls -lh debug-apks/ + + - name: Upload Debug APK Artifacts + uses: actions/upload-artifact@v4 + with: + name: simple-notes-sync-debug-v${{ env.VERSION_NAME }}-${{ env.BUILD_TIME }} + path: debug-apks/*.apk + retention-days: 30 # Debug Builds länger aufbewahren + compression-level: 0 # APK ist bereits komprimiert + + - name: Create summary + run: | + echo "## 🐛 Debug APK Build" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Build Info" >> $GITHUB_STEP_SUMMARY + echo "- **Version:** v${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})" >> $GITHUB_STEP_SUMMARY + echo "- **Branch:** ${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ env.COMMIT_SHA }}" >> $GITHUB_STEP_SUMMARY + echo "- **Built:** ${{ env.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Download" >> $GITHUB_STEP_SUMMARY + echo "Debug APK available in the Artifacts section above (expires in 30 days)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Installation" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "# Enable unknown sources" >> $GITHUB_STEP_SUMMARY + echo "adb install simple-notes-sync-*-debug.apk" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### What's included?" >> $GITHUB_STEP_SUMMARY + echo "- Full Logging enabled" >> $GITHUB_STEP_SUMMARY + echo "- Not production signed" >> $GITHUB_STEP_SUMMARY + echo "- May have performance impact" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/build-production-apk.yml b/.github/workflows/build-production-apk.yml index 4c8b7fe..2a76048 100644 --- a/.github/workflows/build-production-apk.yml +++ b/.github/workflows/build-production-apk.yml @@ -146,6 +146,19 @@ jobs: --- + ## 🔐 APK Signature Verification + + All APKs are signed with the official release certificate. + + **Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app) + + **Expected SHA-256:** + ``` + 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A + ``` + + --- + **[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)** env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3bc6630..240e44d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ Thumbs.db *.swp *~ test-apks/ +server-test/ # F-Droid metadata (managed in fdroiddata repo) # Exclude fastlane metadata (we want to track those screenshots) diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index 0219cf1..da297a4 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -8,6 +8,42 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.7.0] - 2026-01-26 + +### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung + +Pinterest-Style Grid, Nur-WLAN Sync-Modus und korrekte VPN-Unterstützung! + +### 🎨 Grid-Layout + +- Pinterest-Style Staggered Grid ohne Lücken +- Konsistente 12dp Abstände zwischen Cards +- Scroll-Position bleibt erhalten nach Einstellungen +- Neue einheitliche `NoteCardGrid` mit dynamischen Vorschauzeilen (3 klein, 6 groß) + +### 📡 Sync-Verbesserungen + +- **Nur-WLAN Sync Toggle** - Sync nur wenn WLAN verbunden +- **VPN-Unterstützung** - Sync funktioniert korrekt bei aktivem VPN (Traffic über VPN) +- **Server-Wechsel Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei Server-URL Änderung +- **Schnellere Server-Prüfung** - Socket-Timeout von 2s auf 1s reduziert +- **"Sync läuft bereits" Feedback** - Zeigt Snackbar wenn Sync bereits läuft + +### 🔒 Self-Signed SSL Unterstützung + +- **Dokumentation hinzugefügt** - Anleitung für selbst-signierte Zertifikate +- Nutzt Android's eingebauten CA Trust Store +- Funktioniert mit ownCloud, Nextcloud, Synology, Home-Servern + +### 🔧 Technisch + +- `NoteCardGrid` Komponente mit dynamischen maxLines +- FullLine Spans entfernt für lückenloses Layout +- `resetAllSyncStatusToPending()` in NotesStorage +- VPN-Erkennung in `getOrCacheWiFiAddress()` + +--- + ## [1.6.1] - 2026-01-20 ### 🧹 Code-Qualität & Build-Verbesserungen diff --git a/CHANGELOG.md b/CHANGELOG.md index 208279e..6000ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.7.0] - 2026-01-26 + +### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support + +Pinterest-style grid, WiFi-only sync mode, and proper VPN support! + +### 🎨 Grid Layout + +- Pinterest-style staggered grid without gaps +- Consistent 12dp spacing between cards +- Scroll position preserved when returning from settings +- New unified `NoteCardGrid` with dynamic preview lines (3 small, 6 large) + +### 📡 Sync Improvements + +- **WiFi-only sync toggle** - Sync only when connected to WiFi +- **VPN support** - Sync works correctly when VPN is active (traffic routes through VPN) +- **Server change detection** - All notes reset to PENDING when server URL changes +- **Faster server check** - Socket timeout reduced from 2s to 1s +- **"Sync already running" feedback** - Shows snackbar when sync is in progress + +### 🔒 Self-Signed SSL Support + +- **Documentation added** - Guide for using self-signed certificates +- Uses Android's built-in CA trust store +- Works with ownCloud, Nextcloud, Synology, home servers + +### 🔧 Technical + +- `NoteCardGrid` component with dynamic maxLines +- Removed FullLine spans for gapless layout +- `resetAllSyncStatusToPending()` in NotesStorage +- VPN detection in `getOrCacheWiFiAddress()` + +--- + ## [1.6.1] - 2026-01-20 ### 🧹 Code Quality & Build Improvements diff --git a/README.de.md b/README.de.md index 4b5df80..e496e4a 100644 --- a/README.de.md +++ b/README.de.md @@ -97,6 +97,7 @@ docker compose up -d | **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste | | **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung | | **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) | +| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL Zertifikat Setup | | **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting | | **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie | | **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 | @@ -129,6 +130,6 @@ MIT License - siehe [LICENSE](LICENSE)
-**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3 +**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
diff --git a/README.md b/README.md index 9a5e3fa..6f381c1 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ docker compose up -d --- +## 🔐 APK Verification + +All official releases are signed with the same certificate. + +**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app) + +**Expected SHA-256:** +``` +42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A +``` + +--- + ## 📚 Documentation | Document | Content | @@ -99,6 +112,7 @@ docker compose up -d | **[FEATURES.md](docs/FEATURES.md)** | Complete feature list | | **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide | | **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) | +| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup | | **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting | | **[CHANGELOG.md](CHANGELOG.md)** | Version history | | **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 | @@ -127,6 +141,6 @@ MIT License - see [LICENSE](LICENSE)
-**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3 +**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5c0ef64..82d4c85 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 = 16 // 🔧 v1.6.2: Hotfix offline mode migration bug - versionName = "1.6.2" // 🔧 v1.6.2: Hotfix offline mode migration bug + versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption + versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -99,6 +99,11 @@ android { compose = true // v1.5.0: Jetpack Compose für Settings Redesign } + // v1.7.0: Mock Android framework classes in unit tests (Log, etc.) + testOptions { + unitTests.isReturnDefaultValues = true + } + // v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard // composeCompiler { } @@ -140,6 +145,9 @@ dependencies { // SwipeRefreshLayout für Pull-to-Refresh implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + // 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung + implementation("androidx.security:security-crypto:1.1.0-alpha06") + // ═══════════════════════════════════════════════════════════════════════ // v1.5.0: Jetpack Compose für Settings Redesign // ═══════════════════════════════════════════════════════════════════════ 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 e6db9d5..5dbd53b 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 @@ -31,20 +31,24 @@ class BackupManager(private val context: Context) { private const val BACKUP_VERSION = 1 private const val AUTO_BACKUP_DIR = "auto_backups" private const val AUTO_BACKUP_RETENTION_DAYS = 7 + private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check } private val storage = NotesStorage(context) private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + private val encryptionManager = EncryptionManager() // 🔐 v1.7.0 /** * Erstellt Backup aller Notizen * * @param uri Output-URI (via Storage Access Framework) + * @param password Optional password for encryption (null = unencrypted) * @return BackupResult mit Erfolg/Fehler Info */ - suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) { + suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) { return@withContext try { - Logger.d(TAG, "📦 Creating backup to: $uri") + val encryptedSuffix = if (password != null) " (encrypted)" else "" + Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri") val allNotes = storage.loadAllNotes() Logger.d(TAG, " Found ${allNotes.size} notes to backup") @@ -59,15 +63,22 @@ class BackupManager(private val context: Context) { val jsonString = gson.toJson(backupData) + // 🔐 v1.7.0: Encrypt if password is provided + val dataToWrite = if (password != null) { + encryptionManager.encrypt(jsonString.toByteArray(), password) + } else { + jsonString.toByteArray() + } + context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(jsonString.toByteArray()) - Logger.d(TAG, "✅ Backup created successfully") + outputStream.write(dataToWrite) + Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix") } BackupResult( success = true, notesCount = allNotes.size, - message = "Backup erstellt: ${allNotes.size} Notizen" + message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix" ) } catch (e: Exception) { @@ -126,20 +137,42 @@ class BackupManager(private val context: Context) { * * @param uri Backup-Datei URI * @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite) + * @param password Optional password if backup is encrypted * @return RestoreResult mit Details */ - suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) { + suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) { return@withContext try { Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)") // 1. Backup-Datei lesen - val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.bufferedReader().use { it.readText() } + val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.readBytes() } ?: return@withContext RestoreResult( success = false, error = "Datei konnte nicht gelesen werden" ) + // 🔐 v1.7.0: Check if encrypted and decrypt if needed + val jsonString = try { + if (encryptionManager.isEncrypted(fileData)) { + if (password == null) { + return@withContext RestoreResult( + success = false, + error = "Backup ist verschlüsselt. Bitte Passwort eingeben." + ) + } + val decrypted = encryptionManager.decrypt(fileData, password) + String(decrypted) + } else { + String(fileData) + } + } catch (e: EncryptionException) { + return@withContext RestoreResult( + success = false, + error = "Entschlüsselung fehlgeschlagen: ${e.message}" + ) + } + // 2. Backup validieren & parsen val validationResult = validateBackup(jsonString) if (!validationResult.isValid) { @@ -177,6 +210,22 @@ class BackupManager(private val context: Context) { } } + /** + * 🔐 v1.7.0: Check if backup file is encrypted + */ + suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) { + return@withContext try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val header = ByteArray(MAGIC_BYTES_LENGTH) + val bytesRead = inputStream.read(header) + bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header) + } ?: false + } catch (e: Exception) { + Logger.e(TAG, "Failed to check encryption status", e) + false + } + } + /** * Validiert Backup-Datei */ diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/backup/EncryptionManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/backup/EncryptionManager.kt new file mode 100644 index 0000000..d113690 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/backup/EncryptionManager.kt @@ -0,0 +1,172 @@ +package dev.dettmer.simplenotes.backup + +import dev.dettmer.simplenotes.utils.Logger +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * 🔐 v1.7.0: Encryption Manager for Backup Files + * + * Provides AES-256-GCM encryption for local backups with: + * - Password-based encryption (PBKDF2 key derivation) + * - Random salt + IV for each encryption + * - GCM authentication tag for integrity + * - Simple file format: [MAGIC][VERSION][SALT][IV][ENCRYPTED_DATA] + */ +class EncryptionManager { + + companion object { + private const val TAG = "EncryptionManager" + + // File format constants + private const val MAGIC = "SNE1" // Simple Notes Encrypted v1 + private const val VERSION: Byte = 1 + private const val MAGIC_BYTES = 4 + private const val VERSION_BYTES = 1 + private const val SALT_LENGTH = 32 // 256 bits + private const val IV_LENGTH = 12 // 96 bits (recommended for GCM) + private const val HEADER_LENGTH = MAGIC_BYTES + VERSION_BYTES + SALT_LENGTH + IV_LENGTH // 49 bytes + + // Encryption constants + private const val KEY_LENGTH = 256 // AES-256 + private const val GCM_TAG_LENGTH = 128 // 128 bits + private const val PBKDF2_ITERATIONS = 100_000 // OWASP recommendation + + // Algorithm names + private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" + private const val ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding" + } + + /** + * Encrypt data with password + * + * @param data Plaintext data to encrypt + * @param password User password + * @return Encrypted byte array with header [MAGIC][VERSION][SALT][IV][CIPHERTEXT] + */ + fun encrypt(data: ByteArray, password: String): ByteArray { + Logger.d(TAG, "🔐 Encrypting ${data.size} bytes...") + + // Generate random salt and IV + val salt = ByteArray(SALT_LENGTH) + val iv = ByteArray(IV_LENGTH) + SecureRandom().apply { + nextBytes(salt) + nextBytes(iv) + } + + // Derive encryption key from password + val key = deriveKey(password, salt) + + // Encrypt data + val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM) + val secretKey = SecretKeySpec(key, "AES") + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) + val ciphertext = cipher.doFinal(data) + + // Build encrypted file: MAGIC + VERSION + SALT + IV + CIPHERTEXT + val result = ByteBuffer.allocate(HEADER_LENGTH + ciphertext.size).apply { + put(MAGIC.toByteArray(StandardCharsets.US_ASCII)) + put(VERSION) + put(salt) + put(iv) + put(ciphertext) + }.array() + + Logger.d(TAG, "✅ Encryption successful: ${result.size} bytes (header: $HEADER_LENGTH, ciphertext: ${ciphertext.size})") + return result + } + + /** + * Decrypt data with password + * + * @param encryptedData Encrypted byte array (with header) + * @param password User password + * @return Decrypted plaintext + * @throws EncryptionException if decryption fails (wrong password, corrupted data, etc.) + */ + @Suppress("ThrowsCount") // Multiple validation steps require separate throws + fun decrypt(encryptedData: ByteArray, password: String): ByteArray { + Logger.d(TAG, "🔓 Decrypting ${encryptedData.size} bytes...") + + // Validate minimum size + if (encryptedData.size < HEADER_LENGTH) { + throw EncryptionException("File too small: ${encryptedData.size} bytes (expected at least $HEADER_LENGTH)") + } + + // Parse header + val buffer = ByteBuffer.wrap(encryptedData) + + // Verify magic bytes + val magic = ByteArray(MAGIC_BYTES) + buffer.get(magic) + val magicString = String(magic, StandardCharsets.US_ASCII) + if (magicString != MAGIC) { + throw EncryptionException("Invalid file format: expected '$MAGIC', got '$magicString'") + } + + // Check version + val version = buffer.get() + if (version != VERSION) { + throw EncryptionException("Unsupported version: $version (expected $VERSION)") + } + + // Extract salt and IV + val salt = ByteArray(SALT_LENGTH) + val iv = ByteArray(IV_LENGTH) + buffer.get(salt) + buffer.get(iv) + + // Extract ciphertext + val ciphertext = ByteArray(buffer.remaining()) + buffer.get(ciphertext) + + // Derive key from password + val key = deriveKey(password, salt) + + // Decrypt + return try { + val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM) + val secretKey = SecretKeySpec(key, "AES") + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) + val plaintext = cipher.doFinal(ciphertext) + + Logger.d(TAG, "✅ Decryption successful: ${plaintext.size} bytes") + plaintext + } catch (e: Exception) { + Logger.e(TAG, "Decryption failed", e) + throw EncryptionException("Decryption failed: ${e.message}. Wrong password?", e) + } + } + + /** + * Derive 256-bit encryption key from password using PBKDF2 + */ + private fun deriveKey(password: String, salt: ByteArray): ByteArray { + val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH) + val factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM) + return factory.generateSecret(spec).encoded + } + + /** + * Check if data is encrypted (starts with magic bytes) + */ + fun isEncrypted(data: ByteArray): Boolean { + if (data.size < MAGIC_BYTES) return false + val magic = data.sliceArray(0 until MAGIC_BYTES) + return String(magic, StandardCharsets.US_ASCII) == MAGIC + } +} + +/** + * Exception thrown when encryption/decryption fails + */ +class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) 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 5c64a3a..b8dc364 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 @@ -323,6 +323,34 @@ type: ${noteType.name.lowercase()} } } +/** + * 🎨 v1.7.0: Note size classification for Staggered Grid Layout + */ +enum class NoteSize { + SMALL, // Compact display (< 80 chars or ≤ 4 checklist items) + LARGE; // Full-width display + + companion object { + const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note + const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist + } +} + +/** + * 🎨 v1.7.0: Determine note size for grid layout optimization + */ +fun Note.getSize(): NoteSize { + return when (noteType) { + NoteType.TEXT -> { + if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE + } + NoteType.CHECKLIST -> { + val itemCount = checklistItems?.size ?: 0 + if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE + } + } +} + // Extension für JSON-Escaping fun String.escapeJson(): String { return this diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt index a439735..9fafe43 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt @@ -123,6 +123,26 @@ class NotesStorage(private val context: Context) { saveDeletionTracker(DeletionTracker()) Logger.d(TAG, "🗑️ Deletion tracker cleared") } + + /** + * 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes + * This ensures notes are uploaded to the new server on next sync + */ + fun resetAllSyncStatusToPending(): Int { + val notes = loadAllNotes() + var updatedCount = 0 + + notes.forEach { note -> + if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) { + val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING) + saveNote(updatedNote) + updatedCount++ + } + } + + Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING") + return updatedCount + } fun getNotesDir(): File = notesDir 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 9894b11..3f3778e 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 @@ -41,7 +41,7 @@ class WebDavSyncService(private val context: Context) { companion object { private const val TAG = "WebDavSyncService" - private const val SOCKET_TIMEOUT_MS = 2000 + private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s private const val MAX_FILENAME_LENGTH = 200 private const val ETAG_PREVIEW_LENGTH = 8 private const val CONTENT_PREVIEW_LENGTH = 50 @@ -130,7 +130,14 @@ class WebDavSyncService(private val context: Context) { return null } - // Nur wenn WiFi aktiv + // 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active + // When VPN is active, traffic should route through VPN, not directly via WiFi + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)") + return null + } + + // Nur wenn WiFi aktiv (und kein VPN) if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { Logger.d(TAG, "⚠️ Not on WiFi, using default routing") return null @@ -556,6 +563,25 @@ class WebDavSyncService(private val context: Context) { } } + /** + * 🆕 v1.7.0: Prüft ob Gerät aktuell im WLAN ist + * Für schnellen Pre-Check VOR dem langsamen Socket-Check + * + * @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk) + */ + fun isOnWiFi(): Boolean { + return try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as? ConnectivityManager ?: return false + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } catch (e: Exception) { + Logger.e(TAG, "Failed to check WiFi state", e) + false + } + } + suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) { return@withContext try { val sardine = getOrCreateSardine() ?: return@withContext SyncResult( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt index ad7f422..d5f53fa 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt @@ -83,6 +83,11 @@ fun NoteEditorScreen( var focusNewItemId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + // Strings for toast messages (avoid LocalContextGetResourceValueCall lint) + val msgNoteIsEmpty = stringResource(R.string.note_is_empty) + val msgNoteSaved = stringResource(R.string.note_saved) + val msgNoteDeleted = stringResource(R.string.note_deleted) + // v1.5.0: Auto-keyboard support val keyboardController = LocalSoftwareKeyboardController.current val titleFocusRequester = remember { FocusRequester() } @@ -111,9 +116,9 @@ fun NoteEditorScreen( when (event) { is NoteEditorEvent.ShowToast -> { val message = when (event.message) { - ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty) - ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved) - ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted) + ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty + ToastMessage.NOTE_SAVED -> msgNoteSaved + ToastMessage.NOTE_DELETED -> msgNoteDeleted } context.showToast(message) } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt index b3d5803..40382bf 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt @@ -183,6 +183,9 @@ class ComposeMainActivity : ComponentActivity() { // This ensures UI reflects current offline mode when returning from Settings viewModel.refreshOfflineModeState() + // 🎨 v1.7.0: Refresh display mode when returning from Settings + viewModel.refreshDisplayMode() + // Register BroadcastReceiver for Background-Sync @Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional LocalBroadcastManager.getInstance(this).registerReceiver( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt index 9c4b528..eac76cc 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete @@ -50,6 +51,7 @@ import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.EmptyState import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB import dev.dettmer.simplenotes.ui.main.components.NotesList +import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner import kotlinx.coroutines.launch @@ -82,12 +84,17 @@ fun MainScreen( // 🌟 v1.6.0: Reactive offline mode state val isOfflineMode by viewModel.isOfflineMode.collectAsState() + // 🎨 v1.7.0: Display mode (list or grid) + val displayMode by viewModel.displayMode.collectAsState() + // Delete confirmation dialog state var showBatchDeleteDialog by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val listState = rememberLazyListState() + // 🎨 v1.7.0: gridState für Staggered Grid Layout + val gridState = rememberLazyStaggeredGridState() // Compute isSyncing once val isSyncing = syncState == SyncStateManager.SyncState.SYNCING @@ -116,9 +123,14 @@ fun MainScreen( } // Phase 3: Scroll to top when new note created + // 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid) LaunchedEffect(scrollToTop) { if (scrollToTop) { - listState.animateScrollToItem(0) + if (displayMode == "grid") { + gridState.animateScrollToItem(0) + } else { + listState.animateScrollToItem(0) + } viewModel.resetScrollToTop() } } @@ -177,22 +189,44 @@ fun MainScreen( if (notes.isEmpty()) { EmptyState(modifier = Modifier.weight(1f)) } else { - NotesList( - notes = notes, - showSyncStatus = viewModel.isServerConfigured(), - selectedNotes = selectedNotes, - isSelectionMode = isSelectionMode, - listState = listState, - modifier = Modifier.weight(1f), - onNoteClick = { note -> onOpenNote(note.id) }, - onNoteLongPress = { note -> - // Long-press starts selection mode - viewModel.startSelectionMode(note.id) - }, - onNoteSelectionToggle = { note -> - viewModel.toggleNoteSelection(note.id) - } - ) + // 🎨 v1.7.0: Switch between List and Grid based on display mode + if (displayMode == "grid") { + NotesStaggeredGrid( + notes = notes, + gridState = gridState, + showSyncStatus = viewModel.isServerConfigured(), + selectedNoteIds = selectedNotes, + isSelectionMode = isSelectionMode, + modifier = Modifier.weight(1f), + onNoteClick = { note -> + if (isSelectionMode) { + viewModel.toggleNoteSelection(note.id) + } else { + onOpenNote(note.id) + } + }, + onNoteLongClick = { note -> + viewModel.startSelectionMode(note.id) + } + ) + } else { + NotesList( + notes = notes, + showSyncStatus = viewModel.isServerConfigured(), + selectedNotes = selectedNotes, + isSelectionMode = isSelectionMode, + listState = listState, + modifier = Modifier.weight(1f), + onNoteClick = { note -> onOpenNote(note.id) }, + onNoteLongPress = { note -> + // Long-press starts selection mode + viewModel.startSelectionMode(note.id) + }, + onNoteSelectionToggle = { note -> + viewModel.toggleNoteSelection(note.id) + } + ) + } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index 4c27aa7..44524cd 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -11,7 +11,6 @@ import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger -import dev.dettmer.simplenotes.utils.SyncConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -83,6 +82,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue") } + // ═══════════════════════════════════════════════════════════════════════ + // 🎨 v1.7.0: Display Mode State + // ═══════════════════════════════════════════════════════════════════════ + + private val _displayMode = MutableStateFlow( + prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE + ) + val displayMode: StateFlow = _displayMode.asStateFlow() + + /** + * Refresh display mode from SharedPreferences + * Called when returning from Settings screen + */ + fun refreshDisplayMode() { + val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE + _displayMode.value = newValue + Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue") + } + // ═══════════════════════════════════════════════════════════════════════ // Sync State (derived from SyncStateManager) // ═══════════════════════════════════════════════════════════════════════ @@ -490,7 +508,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } + // 🆕 v1.7.0: WiFi-Only Check (sofort, kein Netzwerk-Wait) + val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) + if (wifiOnlySync) { + val syncService = WebDavSyncService(getApplication()) + if (!syncService.isOnWiFi()) { + Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi") + SyncStateManager.markError(getString(R.string.sync_wifi_only_hint)) + return + } + } + + // 🆕 v1.7.0: Feedback wenn Sync bereits läuft if (!SyncStateManager.tryStartSync(source)) { + if (SyncStateManager.isSyncing) { + Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress") + viewModelScope.launch { + _showSnackbar.emit(SnackbarData( + message = getString(R.string.sync_already_running), + actionLabel = "", + onAction = {} + )) + } + } return } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt index d352b59..2e6fc74 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt @@ -72,7 +72,7 @@ fun NoteCard( Card( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) + // 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände .then( if (isSelected) { Modifier.border( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt new file mode 100644 index 0000000..c51ebd8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt @@ -0,0 +1,246 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.CloudOff +import androidx.compose.material.icons.outlined.CloudSync +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.utils.toReadableTime + +/** + * 🎨 v1.7.0: Compact Note Card for Grid Layout + * + * COMPACT DESIGN für kleine Notizen: + * - Reduzierter Padding (12dp statt 16dp) + * - Kleinere Icons (24dp statt 32dp) + * - Kompakte Typography (titleSmall) + * - Max 3 Zeilen Preview + * - Optimiert für Grid-Ansicht + */ +@Composable +fun NoteCardCompact( + note: Note, + showSyncStatus: Boolean, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val context = LocalContext.current + + Card( + modifier = modifier + .fillMaxWidth() + .then( + if (isSelected) { + Modifier.border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp) + ) + } else Modifier + ) + .pointerInput(note.id, isSelectionMode) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = { onLongClick() } + ) + }, + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ) + ) { + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + // Header row - COMPACT + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Type icon - SMALLER + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (note.noteType == NoteType.TEXT) + Icons.Outlined.Description + else + Icons.AutoMirrored.Outlined.List, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(12.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Title - COMPACT Typography + Text( + text = note.title.ifEmpty { stringResource(R.string.untitled) }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Preview - MAX 3 ZEILEN + Text( + text = when (note.noteType) { + NoteType.TEXT -> note.content + NoteType.CHECKLIST -> { + note.checklistItems + ?.joinToString("\n") { item -> + val prefix = if (item.isChecked) "✅" else "☐" + "$prefix ${item.text}" + } ?: "" + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // Bottom row - KOMPAKT + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Timestamp - SMALLER + Text( + text = note.updatedAt.toReadableTime(context), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + + // Sync Status - KOMPAKT + if (showSyncStatus) { + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = when (note.syncStatus) { + SyncStatus.SYNCED -> Icons.Outlined.CloudDone + SyncStatus.PENDING -> Icons.Outlined.CloudSync + SyncStatus.CONFLICT -> Icons.Default.Warning + SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff + }, + contentDescription = null, + tint = when (note.syncStatus) { + SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary + SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.outline + }, + modifier = Modifier.size(14.dp) + ) + } + } + } + + // Selection indicator checkbox (top-right) + androidx.compose.animation.AnimatedVisibility( + visible = isSelectionMode, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + ) + .border( + width = 2.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.selection_count, 1), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(12.dp) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt new file mode 100644 index 0000000..b60d44f --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt @@ -0,0 +1,250 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.CloudOff +import androidx.compose.material.icons.outlined.CloudSync +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteSize +import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.models.getSize +import dev.dettmer.simplenotes.utils.toReadableTime + +/** + * 🎨 v1.7.0: Unified Note Card for Grid Layout + * + * Einheitliche Card für ALLE Notizen im Grid: + * - Dynamische maxLines basierend auf NoteSize + * - LARGE notes: 6 Zeilen Preview + * - SMALL notes: 3 Zeilen Preview + * - Kein externes Padding - Grid steuert Abstände + * - Optimiert für Pinterest-style dynamisches Layout + */ +@Composable +fun NoteCardGrid( + note: Note, + showSyncStatus: Boolean, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val context = LocalContext.current + val noteSize = note.getSize() + + // Dynamische maxLines basierend auf Größe + val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3 + + Card( + modifier = Modifier + .fillMaxWidth() + // Kein externes Padding - Grid steuert alles + .then( + if (isSelected) { + Modifier.border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp) + ) + } else Modifier + ) + .pointerInput(note.id, isSelectionMode) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = { onLongClick() } + ) + }, + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ) + ) { + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) // Einheitliches internes Padding + ) { + // Header row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Type icon + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (note.noteType == NoteType.TEXT) + Icons.Outlined.Description + else + Icons.AutoMirrored.Outlined.List, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(12.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Title + Text( + text = note.title.ifEmpty { stringResource(R.string.untitled) }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Preview - Dynamische Zeilen basierend auf NoteSize + Text( + text = when (note.noteType) { + NoteType.TEXT -> note.content + NoteType.CHECKLIST -> { + note.checklistItems + ?.joinToString("\n") { item -> + val prefix = if (item.isChecked) "✅" else "☐" + "$prefix ${item.text}" + } ?: "" + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = previewMaxLines, // 🎯 Dynamisch: LARGE=6, SMALL=3 + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // Footer + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = note.updatedAt.toReadableTime(context), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + + if (showSyncStatus) { + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = when (note.syncStatus) { + SyncStatus.SYNCED -> Icons.Outlined.CloudDone + SyncStatus.PENDING -> Icons.Outlined.CloudSync + SyncStatus.CONFLICT -> Icons.Default.Warning + SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff + }, + contentDescription = null, + tint = when (note.syncStatus) { + SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary + SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.outline + }, + modifier = Modifier.size(14.dp) + ) + } + } + } + + // Selection indicator checkbox (top-right) + androidx.compose.animation.AnimatedVisibility( + visible = isSelectionMode, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + ) + .border( + width = 2.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = stringResource(R.string.selection_count, 1), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(12.dp) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt index 46c4926..593d018 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesList.kt @@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.main.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -49,6 +50,8 @@ fun NotesList( showSyncStatus = showSyncStatus, isSelected = isSelected, isSelectionMode = isSelectionMode, + // 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), onClick = { if (isSelectionMode) { // In selection mode, tap toggles selection diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesStaggeredGrid.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesStaggeredGrid.kt new file mode 100644 index 0000000..efa6463 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NotesStaggeredGrid.kt @@ -0,0 +1,70 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.utils.Constants + +/** + * 🎨 v1.7.0: Staggered Grid Layout - OPTIMIERT + * + * Pinterest-style Grid: + * - ALLE Items als SingleLane (halbe Breite) + * - Dynamische Höhe basierend auf NoteSize (LARGE=6 Zeilen, SMALL=3 Zeilen) + * - Keine Lücken mehr durch FullLine-Items + * - Selection mode support + * - Efficient LazyVerticalStaggeredGrid + */ +@Composable +fun NotesStaggeredGrid( + notes: List, + gridState: LazyStaggeredGridState, + showSyncStatus: Boolean, + selectedNoteIds: Set, + isSelectionMode: Boolean, + modifier: Modifier = Modifier, + onNoteClick: (Note) -> Unit, + onNoteLongClick: (Note) -> Unit +) { + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS), + modifier = modifier.fillMaxSize(), + state = gridState, + // 🎨 v1.7.0: Konsistente Abstände - 16dp horizontal wie Liste, mehr Platz für FAB + contentPadding = PaddingValues( + start = 16.dp, // Wie Liste, war 8dp + end = 16.dp, + top = 8.dp, + bottom = 80.dp // Mehr Platz für FAB, war 16dp + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), // War 8dp + verticalItemSpacing = 12.dp // War Constants.GRID_SPACING_DP (8dp) + ) { + items( + items = notes, + key = { it.id } + // 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite) + ) { note -> + val isSelected = selectedNoteIds.contains(note.id) + + // 🎉 Einheitliche Card für alle Größen - dynamische maxLines intern + NoteCardGrid( + note = note, + showSyncStatus = showSyncStatus, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onClick = { onNoteClick(note) }, + onLongClick = { onNoteLongClick(note) } + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt index 4c18631..3d9c59b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.compose.composable import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen +import dev.dettmer.simplenotes.ui.settings.screens.DisplaySettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen @@ -95,5 +96,13 @@ fun SettingsNavHost( onBack = { navController.popBackStack() } ) } + + // 🎨 v1.7.0: Display Settings + composable(SettingsRoute.Display.route) { + DisplaySettingsScreen( + viewModel = viewModel, + onBack = { navController.popBackStack() } + ) + } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt index ffd3a23..2a11fa7 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt @@ -13,4 +13,5 @@ sealed class SettingsRoute(val route: String) { data object Backup : SettingsRoute("settings_backup") data object About : SettingsRoute("settings_about") data object Debug : SettingsRoute("settings_debug") + data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0 } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt index 04f26fb..dc5eed8 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.backup.BackupManager import dev.dettmer.simplenotes.backup.RestoreMode +import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger @@ -33,6 +34,7 @@ import java.net.URL * * Manages all settings state and actions across the Settings navigation graph. */ +@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*) class SettingsViewModel(application: Application) : AndroidViewModel(application) { companion object { @@ -42,6 +44,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val backupManager = BackupManager(application) + private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection + + // 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection + // This prevents false-positive "server changed" toasts during text input + private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" // ═══════════════════════════════════════════════════════════════════════ // Server Settings State @@ -154,6 +161,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application ) val triggerBoot: StateFlow = _triggerBoot.asStateFlow() + // 🎉 v1.7.0: WiFi-Only Sync Toggle + private val _wifiOnlySync = MutableStateFlow( + prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) + ) + val wifiOnlySync: StateFlow = _wifiOnlySync.asStateFlow() + // ═══════════════════════════════════════════════════════════════════════ // Markdown Settings State // ═══════════════════════════════════════════════════════════════════════ @@ -173,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application ) val fileLoggingEnabled: StateFlow = _fileLoggingEnabled.asStateFlow() + // ═══════════════════════════════════════════════════════════════════════ + // 🎨 v1.7.0: Display Settings State + // ═══════════════════════════════════════════════════════════════════════ + + private val _displayMode = MutableStateFlow( + prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE + ) + val displayMode: StateFlow = _displayMode.asStateFlow() + // ═══════════════════════════════════════════════════════════════════════ // UI State // ═══════════════════════════════════════════════════════════════════════ @@ -216,41 +238,130 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application /** * 🌟 v1.6.0: Update only the host part of the server URL * The protocol prefix is handled separately by updateProtocol() + * 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection + * 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) + * but WITHOUT server-change detection (detection happens only on screen exit) */ fun updateServerHost(host: String) { _serverHost.value = host - saveServerSettings() + + // ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection + val prefix = if (_isHttps.value) "https://" else "http://" + val fullUrl = if (host.isEmpty()) "" else prefix + host + prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply() } fun updateProtocol(useHttps: Boolean) { _isHttps.value = useHttps // 🌟 v1.6.0: Host stays the same, only prefix changes - saveServerSettings() + // 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection + // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) + + // ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection + val prefix = if (useHttps) "https://" else "http://" + val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value + prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply() } fun updateUsername(value: String) { _username.value = value - saveServerSettings() + // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) + prefs.edit().putString(Constants.KEY_USERNAME, value).apply() } fun updatePassword(value: String) { _password.value = value - saveServerSettings() + // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService) + prefs.edit().putString(Constants.KEY_PASSWORD, value).apply() } - private fun saveServerSettings() { + /** + * 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen + * This prevents false "server changed" detection during text input + * 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions. + * This function now ONLY handles server-change detection and sync reset. + */ + fun saveServerSettingsManually() { // 🌟 v1.6.0: Construct full URL from prefix + host val prefix = if (_isHttps.value) "https://" else "http://" val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value - prefs.edit().apply { - putString(Constants.KEY_SERVER_URL, fullUrl) - putString(Constants.KEY_USERNAME, _username.value) - putString(Constants.KEY_PASSWORD, _password.value) - apply() + // 🔄 v1.7.0: Detect server change ONLY against last confirmed URL + val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl) + + // ✅ Settings are already saved in updateServerHost/Protocol/Username/Password + // This function now ONLY handles server-change detection + + // Reset sync status if server actually changed + if (serverChanged) { + viewModelScope.launch { + val count = notesStorage.resetAllSyncStatusToPending() + Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING") + emitToast(getString(R.string.toast_server_changed_sync_reset, count)) + } + // Update confirmed state after reset + confirmedServerUrl = fullUrl + } else { + Logger.d(TAG, "💾 Server settings check complete (no server change detected)") } } + /** + * � v1.7.0 Hotfix: Improved server change detection + * + * Only returns true if the server URL actually changed in a meaningful way. + * Handles edge cases: + * - First setup (empty → filled) = NOT a change + * - Protocol only (http → https) = NOT a change + * - Server removed (filled → empty) = NOT a change + * - Trailing slashes, case differences = NOT a change + * - Different hostname/port/path = IS a change ✓ + */ + private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean { + // Empty → Non-empty = First setup, NOT a change + if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) { + Logger.d(TAG, "First server setup detected (no reset needed)") + return false + } + + // Both empty = No change + if (confirmedUrl.isEmpty() && newUrl.isEmpty()) { + return false + } + + // Non-empty → Empty = Server removed (keep notes local, no reset) + if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) { + Logger.d(TAG, "Server removed (notes stay local, no reset needed)") + return false + } + + // Same URL = No change + if (confirmedUrl == newUrl) { + return false + } + + // Normalize URLs for comparison (ignore protocol, trailing slash, case) + val normalize = { url: String -> + url.trim() + .removePrefix("http://") + .removePrefix("https://") + .removeSuffix("/") + .lowercase() + } + + val confirmedNormalized = normalize(confirmedUrl) + val newNormalized = normalize(newUrl) + + // Check if normalized URLs differ + val changed = confirmedNormalized != newNormalized + + if (changed) { + Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'") + } + + return changed + } + fun testConnection() { viewModelScope.launch { _serverStatus.value = ServerStatus.Checking @@ -412,6 +523,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application Logger.d(TAG, "Trigger Boot: $enabled") } + /** + * 🎉 v1.7.0: Set WiFi-only sync mode + * When enabled, sync only happens when connected to WiFi + */ + fun setWifiOnlySync(enabled: Boolean) { + _wifiOnlySync.value = enabled + prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply() + Logger.d(TAG, "📡 WiFi-only sync: $enabled") + } + // ═══════════════════════════════════════════════════════════════════════ // Markdown Settings Actions // ═══════════════════════════════════════════════════════════════════════ @@ -519,11 +640,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application // Backup Actions // ═══════════════════════════════════════════════════════════════════════ - fun createBackup(uri: Uri) { + fun createBackup(uri: Uri, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true try { - val result = backupManager.createBackup(uri) + val result = backupManager.createBackup(uri, password) val message = if (result.success) { getString(R.string.toast_backup_success, result.message ?: "") } else { @@ -538,11 +659,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application } } - fun restoreFromFile(uri: Uri, mode: RestoreMode) { + fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true try { - val result = backupManager.restoreBackup(uri, mode) + val result = backupManager.restoreBackup(uri, mode, password) val message = if (result.success) { getString(R.string.toast_restore_success, result.importedNotes) } else { @@ -557,6 +678,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application } } + /** + * 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback + */ + fun checkBackupEncryption( + uri: Uri, + onEncrypted: () -> Unit, + onPlaintext: () -> Unit + ) { + viewModelScope.launch { + try { + val isEncrypted = backupManager.isBackupEncrypted(uri) + if (isEncrypted) { + onEncrypted() + } else { + onPlaintext() + } + } catch (e: Exception) { + Logger.e(TAG, "Failed to check encryption status", e) + onPlaintext() // Assume plaintext on error + } + } + } + fun restoreFromServer(mode: RestoreMode) { viewModelScope.launch { _isBackupInProgress.value = true @@ -664,4 +808,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application val total: Int, val isComplete: Boolean = false ) + + // ═══════════════════════════════════════════════════════════════════════ + // 🎨 v1.7.0: Display Mode Functions + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Set display mode (list or grid) + */ + fun setDisplayMode(mode: String) { + _displayMode.value = mode + prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply() + Logger.d(TAG, "Display mode changed to: $mode") + } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/BackupPasswordDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/BackupPasswordDialog.kt new file mode 100644 index 0000000..fca14a0 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/BackupPasswordDialog.kt @@ -0,0 +1,180 @@ +package dev.dettmer.simplenotes.ui.settings.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R + +private const val MIN_PASSWORD_LENGTH = 8 + +/** + * 🔒 v1.7.0: Password input dialog for backup encryption/decryption + */ +@Composable +fun BackupPasswordDialog( + title: String, + onDismiss: () -> Unit, + onConfirm: (password: String) -> Unit, + requireConfirmation: Boolean = true +) { + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var confirmPasswordVisible by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + val focusRequester = remember { FocusRequester() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + // Password field + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text(stringResource(R.string.backup_encryption_password)) }, + placeholder = { Text(stringResource(R.string.backup_encryption_password_hint)) }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = null + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = if (requireConfirmation) ImeAction.Next else ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = if (!requireConfirmation) { + { validateAndConfirm(password, null, onConfirm) { errorMessage = it } } + } else null + ), + singleLine = true, + isError = errorMessage != null, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + // Confirm password field (only for encryption, not decryption) + if (requireConfirmation) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + errorMessage = null + }, + label = { Text(stringResource(R.string.backup_encryption_confirm)) }, + placeholder = { Text(stringResource(R.string.backup_encryption_confirm_hint)) }, + visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { + Icon( + imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = null + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { validateAndConfirm(password, confirmPassword, onConfirm) { errorMessage = it } } + ), + singleLine = true, + isError = errorMessage != null, + modifier = Modifier.fillMaxWidth() + ) + } + + // Error message + if (errorMessage != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = errorMessage!!, + color = androidx.compose.material3.MaterialTheme.colorScheme.error, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + validateAndConfirm( + password, + if (requireConfirmation) confirmPassword else null, + onConfirm + ) { errorMessage = it } + } + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +/** + * Validate password and call onConfirm if valid + */ +private fun validateAndConfirm( + password: String, + confirmPassword: String?, + onConfirm: (String) -> Unit, + onError: (String) -> Unit +) { + when { + password.length < MIN_PASSWORD_LENGTH -> { + onError("Password too short (min. $MIN_PASSWORD_LENGTH characters)") + } + confirmPassword != null && password != confirmPassword -> { + onError("Passwords don't match") + } + else -> { + onConfirm(password) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt index e91ecc3..a748b6d 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.ui.settings.SettingsViewModel +import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog import dev.dettmer.simplenotes.ui.settings.components.RadioOption import dev.dettmer.simplenotes.ui.settings.components.SettingsButton import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider @@ -34,6 +35,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader +import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -58,11 +60,25 @@ fun BackupSettingsScreen( var pendingRestoreUri by remember { mutableStateOf(null) } var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) } + // 🔐 v1.7.0: Encryption state + var encryptBackup by remember { mutableStateOf(false) } + var showEncryptionPasswordDialog by remember { mutableStateOf(false) } + var showDecryptionPasswordDialog by remember { mutableStateOf(false) } + var pendingBackupUri by remember { mutableStateOf(null) } + // File picker launchers val createBackupLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/json") ) { uri -> - uri?.let { viewModel.createBackup(it) } + uri?.let { + // 🔐 v1.7.0: If encryption enabled, show password dialog first + if (encryptBackup) { + pendingBackupUri = it + showEncryptionPasswordDialog = true + } else { + viewModel.createBackup(it, password = null) + } + } } val restoreFileLauncher = rememberLauncherForActivityResult( @@ -99,6 +115,16 @@ fun BackupSettingsScreen( Spacer(modifier = Modifier.height(8.dp)) + // 🔐 v1.7.0: Encryption toggle + SettingsSwitch( + title = stringResource(R.string.backup_encryption_title), + subtitle = stringResource(R.string.backup_encryption_subtitle), + checked = encryptBackup, + onCheckedChange = { encryptBackup = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) + SettingsButton( text = stringResource(R.string.backup_create), onClick = { @@ -156,6 +182,47 @@ fun BackupSettingsScreen( } } + // 🔐 v1.7.0: Encryption password dialog (for backup creation) + if (showEncryptionPasswordDialog) { + BackupPasswordDialog( + title = stringResource(R.string.backup_encryption_title), + onDismiss = { + showEncryptionPasswordDialog = false + pendingBackupUri = null + }, + onConfirm = { password -> + showEncryptionPasswordDialog = false + pendingBackupUri?.let { uri -> + viewModel.createBackup(uri, password) + } + pendingBackupUri = null + }, + requireConfirmation = true + ) + } + + // 🔐 v1.7.0: Decryption password dialog (for restore) + if (showDecryptionPasswordDialog) { + BackupPasswordDialog( + title = stringResource(R.string.backup_decryption_required), + onDismiss = { + showDecryptionPasswordDialog = false + pendingRestoreUri = null + }, + onConfirm = { password -> + showDecryptionPasswordDialog = false + pendingRestoreUri?.let { uri -> + when (restoreSource) { + RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password) + RestoreSource.Server -> { /* Server restore doesn't support encryption */ } + } + } + pendingRestoreUri = null + }, + requireConfirmation = false + ) + } + // Restore Mode Dialog if (showRestoreDialog) { RestoreModeDialog( @@ -167,7 +234,17 @@ fun BackupSettingsScreen( when (restoreSource) { RestoreSource.LocalFile -> { pendingRestoreUri?.let { uri -> - viewModel.restoreFromFile(uri, selectedRestoreMode) + // 🔐 v1.7.0: Check if backup is encrypted + viewModel.checkBackupEncryption( + uri = uri, + onEncrypted = { + showDecryptionPasswordDialog = true + }, + onPlaintext = { + viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) + pendingRestoreUri = null + } + ) } } RestoreSource.Server -> { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt index f3bd74b..99ab408 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt @@ -82,6 +82,9 @@ fun DebugSettingsScreen( Spacer(modifier = Modifier.height(8.dp)) // Export Logs Button + val logsSubject = stringResource(R.string.debug_logs_subject) + val logsShareVia = stringResource(R.string.debug_logs_share_via) + SettingsButton( text = stringResource(R.string.debug_export_logs), onClick = { @@ -96,11 +99,11 @@ fun DebugSettingsScreen( val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_STREAM, logUri) - putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject)) + putExtra(Intent.EXTRA_SUBJECT, logsSubject) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via))) + context.startActivity(Intent.createChooser(shareIntent, logsShareVia)) } }, modifier = Modifier.padding(horizontal = 16.dp) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DisplaySettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DisplaySettingsScreen.kt new file mode 100644 index 0000000..f7acaf6 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DisplaySettingsScreen.kt @@ -0,0 +1,74 @@ +package dev.dettmer.simplenotes.ui.settings.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.ui.settings.SettingsViewModel +import dev.dettmer.simplenotes.ui.settings.components.RadioOption +import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard +import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup +import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold +import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader + +/** + * 🎨 v1.7.0: Display Settings Screen + * + * Allows switching between List and Grid view modes. + */ +@Composable +fun DisplaySettingsScreen( + viewModel: SettingsViewModel, + onBack: () -> Unit +) { + val displayMode by viewModel.displayMode.collectAsState() + + SettingsScaffold( + title = stringResource(R.string.display_settings_title), + onBack = onBack + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + SettingsSectionHeader(text = stringResource(R.string.display_mode_title)) + + SettingsRadioGroup( + options = listOf( + RadioOption( + value = "list", + title = stringResource(R.string.display_mode_list), + subtitle = null + ), + RadioOption( + value = "grid", + title = stringResource(R.string.display_mode_grid), + subtitle = null + ) + ), + selectedValue = displayMode, + onValueSelected = { viewModel.setDisplayMode(it) } + ) + + SettingsInfoCard( + text = stringResource(R.string.display_mode_info) + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt index 7fc3f71..9ba7a7b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -56,6 +57,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold * Server configuration settings screen * v1.5.0: Jetpack Compose Settings Redesign * v1.6.0: Offline Mode Toggle + * v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke) */ @Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values @Composable @@ -74,6 +76,14 @@ fun ServerSettingsScreen( var passwordVisible by remember { mutableStateOf(false) } + // 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen + // This prevents false "server changed" detection during text input + DisposableEffect(Unit) { + onDispose { + viewModel.saveServerSettingsManually() + } + } + // Check server status on load (only if not in offline mode) LaunchedEffect(offlineMode) { if (!offlineMode) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt index c0fd92a..ba7ff92 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Sync @@ -90,6 +91,22 @@ fun SettingsMainScreen( ) } + // 🎨 v1.7.0: Display Settings + item { + val displayMode by viewModel.displayMode.collectAsState() + val displaySubtitle = when (displayMode) { + "grid" -> stringResource(R.string.display_mode_grid) + else -> stringResource(R.string.display_mode_list) + } + + SettingsCard( + icon = Icons.Default.GridView, + title = stringResource(R.string.display_settings_title), + subtitle = displaySubtitle, + onClick = { onNavigate(SettingsRoute.Display) } + ) + } + // Server-Einstellungen item { // 🌟 v1.6.0: Check if server is configured (host is not empty) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt index eb51625..2991cb5 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt @@ -50,6 +50,9 @@ fun SyncSettingsScreen( val triggerBoot by viewModel.triggerBoot.collectAsState() val syncInterval by viewModel.syncInterval.collectAsState() + // 🆕 v1.7.0: WiFi-only sync + val wifiOnlySync by viewModel.wifiOnlySync.collectAsState() + // Check if server is configured val isServerConfigured = viewModel.isServerConfigured() @@ -108,6 +111,16 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) + // 🆕 v1.7.0: WiFi-Only Sync Toggle + SettingsSwitch( + title = stringResource(R.string.sync_wifi_only_title), + subtitle = stringResource(R.string.sync_wifi_only_subtitle), + checked = wifiOnlySync, + onCheckedChange = { viewModel.setWifiOnlySync(it) }, + icon = Icons.Default.Wifi, + enabled = isServerConfigured + ) + SettingsDivider() // ═══════════════════════════════════════════════════════════════ diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index 91e95c4..5091b2c 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -32,6 +32,10 @@ object Constants { // 🔥 v1.6.0: Offline Mode Toggle const val KEY_OFFLINE_MODE = "offline_mode_enabled" + // 🔥 v1.7.0: WiFi-Only Sync Toggle + const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled" + const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen + // 🔥 v1.6.0: Configurable Sync Triggers const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save" const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume" @@ -57,4 +61,10 @@ object Constants { // Notifications const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel" const val NOTIFICATION_ID = 1001 + + // 🎨 v1.7.0: Staggered Grid Layout + const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid" + const val DEFAULT_DISPLAY_MODE = "list" + const val GRID_COLUMNS = 2 + const val GRID_SPACING_DP = 8 } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 309a676..6dce156 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -23,6 +23,7 @@ + 📝 Noch keine Notizen Tippe + um eine neue Notiz zu erstellen @@ -196,11 +197,11 @@ Sync-Intervall Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps. ⚡ Alle 15 Minuten - Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh) + Schnellste Synchronisation • ~0.8%% Akku/Tag (~23 mAh) ✓ Alle 30 Minuten (Empfohlen) - Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh) + Ausgewogenes Verhältnis • ~0.4%% Akku/Tag (~12 mAh) 🔋 Alle 60 Minuten - Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt) + Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt) ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag) @@ -224,6 +225,11 @@ Nach Gerät-Neustart Startet Hintergrund-Sync nach Reboot + + Sync nur im WLAN + Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit. + Sync nur im WLAN möglich + Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar. Sync ist im Offline-Modus nicht verfügbar. @@ -253,6 +259,20 @@ Lokales Backup 💾 Backup erstellen 📂 Aus Datei wiederherstellen + + + Backup verschlüsseln + Schütze deine Backup-Datei mit Passwort + Passwort + Passwort eingeben (min. 8 Zeichen) + Passwort bestätigen + Passwort erneut eingeben + Passwörter stimmen nicht überein + Passwort zu kurz (min. 8 Zeichen) + 🔒 Verschlüsseltes Backup + Passwort zum Entschlüsseln eingeben + ❌ Entschlüsselung fehlgeschlagen. Falsches Passwort? + Server-Backup ☁️ Vom Server wiederherstellen ⚠️ Backup wiederherstellen? @@ -308,6 +328,15 @@ ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden. Sprache geändert. Neustart… + + + + Anzeige + Notizen-Ansicht + 📋 Listen-Ansicht + 🎨 Raster-Ansicht + Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein. + @@ -357,6 +386,8 @@ 🗑️ Logs gelöscht 📭 Keine Logs zum Löschen ❌ Fehler beim Löschen: %s + + 🔄 Server geändert. %d Notizen werden beim nächsten Sync hochgeladen. ❌ Fehler beim Öffnen des Links 📝 Datei-Logging aktiviert 📝 Datei-Logging deaktiviert diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec22b9a..e94abb8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -197,11 +197,11 @@ Sync Interval Determines how often the app syncs in the background. Shorter intervals mean more up-to-date data, but use slightly more battery.\n\n⏱️ Note: When your phone is in standby, Android may delay syncs (up to 60 min) to save battery. This is normal and affects all background apps. ⚡ Every 15 minutes - Fastest sync • ~0.8% battery/day (~23 mAh) + Fastest sync • ~0.8%% battery/day (~23 mAh) ✓ Every 30 minutes (Recommended) - Balanced ratio • ~0.4% battery/day (~12 mAh) + Balanced ratio • ~0.4%% battery/day (~12 mAh) 🔋 Every 60 minutes - Maximum battery life • ~0.2% battery/day (~6 mAh est.) + Maximum battery life • ~0.2%% battery/day (~6 mAh est.) ℹ️ Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day) @@ -225,6 +225,11 @@ After Device Restart Starts background sync after reboot + + WiFi-only sync + Sync only when connected to WiFi. Saves mobile data and prevents long wait times. + Sync only works when connected to WiFi + Manual sync (toolbar/pull-to-refresh) is also available. Sync is not available in offline mode. @@ -254,6 +259,20 @@ Local Backup 💾 Create Backup 📂 Restore from File + + + Encrypt Backup + Password-protect your backup file + Password + Enter password (min. 8 characters) + Confirm Password + Re-enter password + Passwords don\'t match + Password too short (min. 8 characters) + 🔒 Encrypted Backup + Enter password to decrypt + ❌ Decryption failed. Wrong password? + Server Backup ☁️ Restore from Server ⚠️ Restore Backup? @@ -309,6 +328,15 @@ ℹ️ Choose your preferred language. The app will restart to apply the change. Language changed. Restarting… + + + + Display + Note Display Mode + 📋 List View + 🎨 Grid View + Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width. + @@ -358,6 +386,8 @@ 🗑️ Logs deleted 📭 No logs to delete ❌ Error deleting: %s + + 🔄 Server changed. %d notes will be uploaded on next sync. ❌ Error opening link 📝 File logging enabled 📝 File logging disabled diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index df80c44..b69e5da 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -11,6 +11,8 @@ + + diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/models/NoteSizeTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/models/NoteSizeTest.kt new file mode 100644 index 0000000..59197ac --- /dev/null +++ b/android/app/src/test/java/dev/dettmer/simplenotes/models/NoteSizeTest.kt @@ -0,0 +1,172 @@ +package dev.dettmer.simplenotes.models + +import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_CHECKLIST_THRESHOLD +import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_TEXT_THRESHOLD +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 🎨 v1.7.0: Tests for Note Size Classification (Staggered Grid Layout) + */ +class NoteSizeTest { + + @Test + fun `text note with less than 80 chars is SMALL`() { + val note = Note( + id = "test1", + title = "Test", + content = "Short content", // 13 chars + deviceId = "test-device", + noteType = NoteType.TEXT + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `text note with exactly 79 chars is SMALL`() { + val content = "x".repeat(79) // Exactly threshold - 1 + val note = Note( + id = "test2", + title = "Test", + content = content, + deviceId = "test-device", + noteType = NoteType.TEXT + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `text note with exactly 80 chars is LARGE`() { + val content = "x".repeat(SMALL_TEXT_THRESHOLD) // Exactly at threshold + val note = Note( + id = "test3", + title = "Test", + content = content, + deviceId = "test-device", + noteType = NoteType.TEXT + ) + + assertEquals(NoteSize.LARGE, note.getSize()) + } + + @Test + fun `text note with more than 80 chars is LARGE`() { + val content = "This is a long note with more than 80 characters. " + + "It should be classified as LARGE for grid layout display." + val note = Note( + id = "test4", + title = "Test", + content = content, + deviceId = "test-device", + noteType = NoteType.TEXT + ) + + assertEquals(NoteSize.LARGE, note.getSize()) + } + + @Test + fun `checklist with 1 item is SMALL`() { + val note = Note( + id = "test5", + title = "Shopping", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = listOf( + ChecklistItem("id1", "Milk", false) + ) + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `checklist with 4 items is SMALL`() { + val note = Note( + id = "test6", + title = "Shopping", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = listOf( + ChecklistItem("id1", "Milk", false), + ChecklistItem("id2", "Bread", false), + ChecklistItem("id3", "Eggs", false), + ChecklistItem("id4", "Butter", false) + ) + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `checklist with 5 items is LARGE`() { + val note = Note( + id = "test7", + title = "Shopping", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = listOf( + ChecklistItem("id1", "Milk", false), + ChecklistItem("id2", "Bread", false), + ChecklistItem("id3", "Eggs", false), + ChecklistItem("id4", "Butter", false), + ChecklistItem("id5", "Cheese", false) // 5th item -> LARGE + ) + ) + + assertEquals(NoteSize.LARGE, note.getSize()) + } + + @Test + fun `checklist with many items is LARGE`() { + val items = (1..10).map { ChecklistItem("id$it", "Item $it", false) } + val note = Note( + id = "test8", + title = "Long List", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = items + ) + + assertEquals(NoteSize.LARGE, note.getSize()) + } + + @Test + fun `empty checklist is SMALL`() { + val note = Note( + id = "test9", + title = "Empty", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = emptyList() + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `checklist with null items is SMALL`() { + val note = Note( + id = "test10", + title = "Null Items", + content = "", + deviceId = "test-device", + noteType = NoteType.CHECKLIST, + checklistItems = null + ) + + assertEquals(NoteSize.SMALL, note.getSize()) + } + + @Test + fun `constants have expected values`() { + assertEquals(80, SMALL_TEXT_THRESHOLD) + assertEquals(4, SMALL_CHECKLIST_THRESHOLD) + } +} diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/utils/EncryptionManagerTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/utils/EncryptionManagerTest.kt new file mode 100644 index 0000000..e92b2e4 --- /dev/null +++ b/android/app/src/test/java/dev/dettmer/simplenotes/utils/EncryptionManagerTest.kt @@ -0,0 +1,205 @@ +package dev.dettmer.simplenotes.utils + +import dev.dettmer.simplenotes.backup.EncryptionException +import dev.dettmer.simplenotes.backup.EncryptionManager +import org.junit.Assert.* +import org.junit.Test +import java.security.SecureRandom +import kotlin.text.Charsets.UTF_8 + +/** + * 🔒 v1.7.0: Tests for Local Backup Encryption + */ +class EncryptionManagerTest { + + private val encryptionManager = EncryptionManager() + + @Test + fun `encrypt and decrypt roundtrip preserves data`() { + val originalData = "This is a test backup with UTF-8: äöü 🔒".toByteArray(UTF_8) + val password = "TestPassword123" + + val encrypted = encryptionManager.encrypt(originalData, password) + val decrypted = encryptionManager.decrypt(encrypted, password) + + assertArrayEquals(originalData, decrypted) + } + + @Test + fun `encrypted data has correct header format`() { + val data = "Test data".toByteArray(UTF_8) + val password = "password123" + + val encrypted = encryptionManager.encrypt(data, password) + + // Check magic bytes "SNE1" + val magic = encrypted.copyOfRange(0, 4) + assertArrayEquals("SNE1".toByteArray(UTF_8), magic) + + // Check version (1 byte = 0x01) + assertEquals(1, encrypted[4].toInt()) + + // Check minimum size: magic(4) + version(1) + salt(32) + iv(12) + ciphertext + tag(16) + assertTrue("Encrypted data too small: ${encrypted.size}", encrypted.size >= 4 + 1 + 32 + 12 + 16) + } + + @Test + fun `isEncrypted returns true for encrypted data`() { + val data = "Test".toByteArray(UTF_8) + val encrypted = encryptionManager.encrypt(data, "password") + + assertTrue(encryptionManager.isEncrypted(encrypted)) + } + + @Test + fun `isEncrypted returns false for plaintext data`() { + val plaintext = "This is not encrypted".toByteArray(UTF_8) + + assertFalse(encryptionManager.isEncrypted(plaintext)) + } + + @Test + fun `isEncrypted returns false for short data`() { + val shortData = "SNE".toByteArray(UTF_8) // Less than 4 bytes + + assertFalse(encryptionManager.isEncrypted(shortData)) + } + + @Test + fun `isEncrypted returns false for wrong magic bytes`() { + val wrongMagic = "FAKE1234567890".toByteArray(UTF_8) + + assertFalse(encryptionManager.isEncrypted(wrongMagic)) + } + + @Test + fun `decrypt with wrong password throws EncryptionException`() { + val data = "Sensitive data".toByteArray(UTF_8) + val correctPassword = "correct123" + val wrongPassword = "wrong123" + + val encrypted = encryptionManager.encrypt(data, correctPassword) + + val exception = assertThrows(EncryptionException::class.java) { + encryptionManager.decrypt(encrypted, wrongPassword) + } + + assertTrue(exception.message?.contains("Decryption failed") == true) + } + + @Test + fun `decrypt corrupted data throws EncryptionException`() { + val data = "Test".toByteArray(UTF_8) + val encrypted = encryptionManager.encrypt(data, "password") + + // Corrupt the ciphertext (skip header: 4 + 1 + 32 + 12 = 49 bytes) + val corrupted = encrypted.copyOf() + if (corrupted.size > 50) { + corrupted[50] = (corrupted[50] + 1).toByte() // Flip one bit + } + + assertThrows(EncryptionException::class.java) { + encryptionManager.decrypt(corrupted, "password") + } + } + + @Test + fun `decrypt data with invalid header throws EncryptionException`() { + val invalidData = "This is not encrypted at all".toByteArray(UTF_8) + + assertThrows(EncryptionException::class.java) { + encryptionManager.decrypt(invalidData, "password") + } + } + + @Test + fun `decrypt truncated data throws EncryptionException`() { + val data = "Test".toByteArray(UTF_8) + val encrypted = encryptionManager.encrypt(data, "password") + + // Truncate to only header + val truncated = encrypted.copyOfRange(0, 20) + + assertThrows(EncryptionException::class.java) { + encryptionManager.decrypt(truncated, "password") + } + } + + @Test + fun `encrypt with different passwords produces different ciphertexts`() { + val data = "Same data".toByteArray(UTF_8) + + val encrypted1 = encryptionManager.encrypt(data, "password1") + val encrypted2 = encryptionManager.encrypt(data, "password2") + + // Different passwords should produce different ciphertexts + assertFalse(encrypted1.contentEquals(encrypted2)) + } + + @Test + fun `encrypt same data twice produces different ciphertexts (different IV)`() { + val data = "Same data".toByteArray(UTF_8) + val password = "same-password" + + val encrypted1 = encryptionManager.encrypt(data, password) + val encrypted2 = encryptionManager.encrypt(data, password) + + // Different IVs should produce different ciphertexts + assertFalse(encrypted1.contentEquals(encrypted2)) + + // But both should decrypt to same original data + val decrypted1 = encryptionManager.decrypt(encrypted1, password) + val decrypted2 = encryptionManager.decrypt(encrypted2, password) + assertArrayEquals(decrypted1, decrypted2) + } + + @Test + fun `encrypt large data (1MB) succeeds`() { + val random = SecureRandom() + val largeData = ByteArray(1024 * 1024) // 1 MB + random.nextBytes(largeData) + val password = "password123" + + val encrypted = encryptionManager.encrypt(largeData, password) + val decrypted = encryptionManager.decrypt(encrypted, password) + + assertArrayEquals(largeData, decrypted) + } + + @Test + fun `encrypt empty data succeeds`() { + val emptyData = ByteArray(0) + val password = "password" + + val encrypted = encryptionManager.encrypt(emptyData, password) + val decrypted = encryptionManager.decrypt(encrypted, password) + + assertArrayEquals(emptyData, decrypted) + } + + @Test + fun `encrypt with empty password succeeds but is unsafe`() { + val data = "Test".toByteArray(UTF_8) + + // Crypto library accepts empty passwords (UI prevents this with validation) + val encrypted = encryptionManager.encrypt(data, "") + val decrypted = encryptionManager.decrypt(encrypted, "") + + assertArrayEquals(data, decrypted) + assertTrue("Empty password should still produce encrypted data", encrypted.size > data.size) + } + + @Test + fun `decrypt with unsupported version throws EncryptionException`() { + val data = "Test".toByteArray(UTF_8) + val encrypted = encryptionManager.encrypt(data, "password") + + // Change version byte to unsupported value (99) + val invalidVersion = encrypted.copyOf() + invalidVersion[4] = 99.toByte() + + assertThrows(EncryptionException::class.java) { + encryptionManager.decrypt(invalidVersion, "password") + } + } +} diff --git a/docs/DEBUG_APK.md b/docs/DEBUG_APK.md new file mode 100644 index 0000000..ec3d84a --- /dev/null +++ b/docs/DEBUG_APK.md @@ -0,0 +1,116 @@ +# Debug APK für Issue-Testing + +Für Bug-Reports und Testing von Fixes brauchst du eine **Debug-APK**. Diese wird automatisch gebaut, wenn du auf speziellen Branches pushst. + +## 🔧 Branch-Struktur für Debug-APKs + +Debug-APKs werden **automatisch** gebaut für diese Branches: + +| Branch-Typ | Zweck | Beispiel | +|-----------|-------|---------| +| `debug/*` | Allgemeines Testing | `debug/wifi-only-sync` | +| `fix/*` | Bug-Fixes testen | `fix/vpn-connection` | +| `feature/*` | Neue Features | `feature/grid-layout` | + +**Andere Branches (main, develop, etc.) bauen KEINE Debug-APKs!** + +## 📥 Debug-APK downloaden + +### 1️⃣ Push zu einem Debug-Branch + +```bash +# Neuen Fix-Branch erstellen +git checkout -b fix/my-bug + +# Deine Änderungen machen +# ... + +# Commit und Push +git add . +git commit -m "fix: beschreibung" +git push origin fix/my-bug +``` + +### 2️⃣ GitHub Actions Workflow starten + +- GitHub → **Actions** Tab +- **Build Debug APK** Workflow sehen +- Warten bis Workflow grün ist ✅ + +### 3️⃣ APK herunterladen + +1. Auf den grünen Workflow-Erfolg warten +2. **Artifacts** Section oben (oder unten im Workflow) +3. `simple-notes-sync-debug-*` herunterladen +4. ZIP-Datei entpacken + +**Wichtig:** Artifacts sind nur **30 Tage** verfügbar! + +## 📱 Installation auf Gerät + +## 📱 Installation auf Gerät + +### Mit ADB (Empfohlen - sauberes Testing) +```bash +# Gerät verbinden +adb devices + +# Debug-APK installieren (alte Version wird nicht gelöscht) +adb install simple-notes-sync-debug.apk + +# Aus dem Gerät entfernen später: +adb uninstall dev.dettmer.simplenotes +``` + +### Manuell auf Gerät +1. Datei auf Android-Gerät kopieren +2. **Einstellungen → Sicherheit → "Unbekannte Quellen" aktivieren** +3. Dateimanager öffnen und APK antippen +4. "Installieren" auswählen + +## ⚠️ Debug-APK vs. Release-APK + +| Feature | Debug | Release | +|---------|-------|---------| +| **Logging** | Voll | Minimal | +| **Signatur** | Debug-Key | Release-Key | +| **Performance** | Langsamer | Schneller | +| **Debugging** | ✅ Möglich | ❌ Nein | +| **Installation** | Mehrmals | Kann Probleme geben | + +## 📊 Was zu testen ist + +1. **Neue Features** - Funktionieren wie beschrieben? +2. **Bug Fixes** - Ist der Bug wirklich behoben? +3. **Kompatibilität** - Funktioniert auf deinem Gerät? +4. **Performance** - Läuft die App flüssig? + +## 📝 Feedback geben + +Bitte schreibe einen Kommentar im **Pull Request** oder **GitHub Issue**: +- ✅ Was funktioniert +- ❌ Was nicht funktioniert +- 📋 Fehler-Logs (adb logcat falls relevant) +- 📱 Gerät/Android-Version + +## 🐛 Logs sammeln + +Falls der App-Entwickler Debug-Logs braucht: + +```bash +# Terminal öffnen mit adb +adb shell pm grant dev.dettmer.simplenotes android.permission.READ_LOGS + +# Logs anschauen (live) +adb logcat | grep simplenotes + +# Logs speichern (Datei) +adb logcat > debug-log.txt + +# Nach Fehler filtern +adb logcat | grep -E "ERROR|Exception|CRASH" +``` + +--- + +**Danke fürs Testing! Dein Feedback hilft uns, die App zu verbessern.** 🙏 diff --git a/docs/SELF_SIGNED_SSL.md b/docs/SELF_SIGNED_SSL.md new file mode 100644 index 0000000..ff295fc --- /dev/null +++ b/docs/SELF_SIGNED_SSL.md @@ -0,0 +1,166 @@ +# Self-Signed SSL Certificate Support + +**Since:** v1.7.0 +**Status:** ✅ Supported + +--- + +## Overview + +Simple Notes Sync now supports connecting to WebDAV servers with self-signed SSL certificates, such as: +- ownCloud/Nextcloud with self-signed certificates +- Synology NAS with default certificates +- Raspberry Pi or home servers +- Internal corporate servers with private CAs + +## How to Use + +### Step 1: Export Your Server's CA Certificate + +**On your server:** + +1. Locate your certificate file (usually `.crt`, `.pem`, or `.der` format) +2. If you created the certificate yourself, you already have it +3. For Synology NAS: Control Panel → Security → Certificate → Export +4. For ownCloud/Nextcloud: Usually in `/etc/ssl/certs/` on the server + +### Step 2: Install Certificate on Android + +**On your Android device:** + +1. **Transfer** the `.crt` or `.pem` file to your phone (via email, USB, etc.) + +2. **Open Settings** → Security → More security settings (or Encryption & credentials) + +3. **Install from storage** / "Install a certificate" + - Choose "CA certificate" + - **Warning:** Android will display a security warning. This is normal. + - Tap "Install anyway" + +4. **Browse** to your certificate file and select it + +5. **Name** it something recognizable (e.g., "My ownCloud CA") + +6. ✅ **Done!** The certificate is now trusted system-wide + +### Step 3: Connect Simple Notes Sync + +1. Open Simple Notes Sync +2. Go to **Settings** → **Server Settings** +3. Enter your **`https://` server URL** as usual +4. The app will now trust your self-signed certificate ✅ + +--- + +## Security Notes + +### ⚠️ Important + +- Installing a CA certificate grants trust to **all** certificates signed by that CA +- Only install certificates from sources you trust +- Android will warn you before installation – read the warning carefully + +### 🔒 Why This is Safe + +- You **manually** install the certificate (conscious decision) +- The app uses Android's native trust store (no custom validation) +- You can remove the certificate anytime from Android Settings +- F-Droid and Google Play compliant (no "trust all" hack) + +--- + +## Troubleshooting + +### Certificate Not Trusted + +**Problem:** App still shows SSL error after installing certificate + +**Solutions:** +1. **Verify installation:** Settings → Security → Trusted credentials → User tab +2. **Check certificate type:** Must be a CA certificate, not a server certificate +3. **Restart app:** Close and reopen Simple Notes Sync +4. **Check URL:** Must use `https://` (not `http://`) + +### "Network Security Policy" Error + +**Problem:** Android 7+ restricts user certificates for apps + +**Solution:** This app is configured to trust user certificates ✅ +If the problem persists, check: +- Certificate is installed in "User" tab (not "System") +- Certificate is not expired +- Server URL matches certificate's Common Name (CN) or Subject Alternative Name (SAN) + +### Self-Signed vs. CA-Signed + +| Type | Installation Required | Security | +|------|---------------------|----------| +| **Self-Signed** | ✅ Yes | Manual trust | +| **Let's Encrypt** | ❌ No | Automatic | +| **Private CA** | ✅ Yes (CA root) | Automatic for all CA-signed certs | + +--- + +## Alternative: Use Let's Encrypt (Recommended) + +If your server is publicly accessible, consider using **Let's Encrypt** for free, automatically-renewed SSL certificates: + +- No manual certificate installation needed +- Trusted by all devices automatically +- Easier for end users + +**Setup guides:** +- [ownCloud Let's Encrypt](https://doc.owncloud.com/server/admin_manual/installation/letsencrypt/) +- [Nextcloud Let's Encrypt](https://docs.nextcloud.com/server/latest/admin_manual/installation/letsencrypt.html) +- [Synology Let's Encrypt](https://kb.synology.com/en-us/DSM/tutorial/How_to_enable_HTTPS_and_create_a_certificate_signing_request_on_your_Synology_NAS) + +--- + +## Technical Details + +### Implementation + +- Uses Android's **Network Security Config** +- Trusts both system and user CA certificates +- No custom TrustManager or hostname verifier +- F-Droid and Play Store compliant + +### Configuration + +File: `android/app/src/main/res/xml/network_security_config.xml` + +```xml + + + + + + +``` + +--- + +## FAQ + +**Q: Do I need to reinstall the certificate after app updates?** +A: No, certificates are stored system-wide, not per-app. + +**Q: Can I use the same certificate for multiple apps?** +A: Yes, once installed, it works for all apps that trust user certificates. + +**Q: How do I remove a certificate?** +A: Settings → Security → Trusted credentials → User tab → Tap certificate → Remove + +**Q: Does this work on Android 14+?** +A: Yes, tested on Android 7 through 15 (API 24-35). + +--- + +## Related Issues + +- [GitHub Issue #X](link) - User request for ownCloud support +- [Feature Analysis](../project-docs/simple-notes-sync/features/SELF_SIGNED_SSL_CERTIFICATES_ANALYSIS.md) - Technical analysis + +--- + +**Need help?** Open an issue on [GitHub](https://github.com/inventory69/simple-notes-sync/issues) diff --git a/fastlane/metadata/android/de-DE/changelogs/17.txt b/fastlane/metadata/android/de-DE/changelogs/17.txt new file mode 100644 index 0000000..41241a1 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/17.txt @@ -0,0 +1,8 @@ +• Grid-Ansicht: Pinterest-Style Layout ohne Lücken +• Grid-Ansicht: Konsistente Abstände, Scroll-Position bleibt erhalten +• Nur-WLAN Sync Toggle in Einstellungen +• VPN-Unterstützung: Sync funktioniert korrekt bei aktivem VPN +• Server-Wechsel: Sync-Status wird für alle Notizen zurückgesetzt +• Self-signed SSL: Dokumentation für eigene Zertifikate +• Schnellere Server-Prüfung (1s Timeout) +• "Sync läuft bereits" Feedback diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 0000000..7304eb1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1,8 @@ +• Grid view: Pinterest-style layout without gaps +• Grid view: Consistent spacing, scroll position preserved +• WiFi-only sync toggle in settings +• VPN support: Sync works correctly when VPN is active +• Server change: Sync status resets to pending for all notes +• Self-signed SSL: Documentation for custom certificates +• Faster server check (1s timeout) +• "Sync already running" feedback From 0df8282eb43d5c4884a434df86d23ec971c76a7b Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 26 Jan 2026 21:42:03 +0100 Subject: [PATCH 2/8] fix(sync): Add WiFi-only check for onSave and background sync - SyncWorker: Add central WiFi-only guard before all sync operations - NoteEditorViewModel: Add WiFi-only check before onSave sync trigger - Prevents notes from syncing over 5G/mobile when WiFi-only is enabled - Fixes: onSave sync ignored WiFi-only setting completely --- .../dettmer/simplenotes/sync/SyncWorker.kt | 27 +++++++++++++++++++ .../ui/editor/NoteEditorViewModel.kt | 10 +++++++ 2 files changed, 37 insertions(+) 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 889112a..f30611d 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 @@ -9,6 +9,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dev.dettmer.simplenotes.BuildConfig +import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.NotificationHelper import kotlinx.coroutines.CancellationException @@ -88,6 +89,32 @@ class SyncWorker( return@withContext Result.success() } + if (BuildConfig.DEBUG) { + Logger.d(TAG, "📍 Step 2.5: Checking WiFi-only setting") + } + + // 🆕 v1.7.0: WiFi-Only Check (zentral für alle Sync-Arten) + val prefs = applicationContext.getSharedPreferences( + Constants.PREFS_NAME, + Context.MODE_PRIVATE + ) + val wifiOnlySync = prefs.getBoolean( + Constants.KEY_WIFI_ONLY_SYNC, + Constants.DEFAULT_WIFI_ONLY_SYNC + ) + + if (wifiOnlySync && !syncService.isOnWiFi()) { + Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync") + Logger.d(TAG, " User can still manually sync when on WiFi") + + if (BuildConfig.DEBUG) { + Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (WiFi-only skip)") + Logger.d(TAG, "═══════════════════════════════════════") + } + + return@withContext Result.success() + } + if (BuildConfig.DEBUG) { Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)") } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt index 77cf761..40da751 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt @@ -372,6 +372,16 @@ class NoteEditorViewModel( return } + // 🆕 v1.7.0: WiFi-Only Check + val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) + if (wifiOnlySync) { + val syncService = WebDavSyncService(getApplication()) + if (!syncService.isOnWiFi()) { + Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi") + return + } + } + // Check 3: Throttling (5 seconds) to prevent spam val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0) val now = System.currentTimeMillis() From cb63aa12204989f358455d3642c08c02f379dbaa Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 26 Jan 2026 22:41:00 +0100 Subject: [PATCH 3/8] fix(sync): Implement central canSync() gate for WiFi-only check - Add WebDavSyncService.canSync() as single source of truth - Add SyncGateResult data class for structured response - Update MainViewModel.triggerManualSync() to use canSync() - Update MainViewModel.triggerAutoSync() to use canSync() - FIXES onResume bug - Update NoteEditorViewModel.triggerOnSaveSync() to use canSync() - Update SettingsViewModel.syncNow() to use canSync() - Update SyncWorker to use canSync() instead of direct prefs check All 9 sync paths now respect WiFi-only setting through one central gate. --- .../dettmer/simplenotes/sync/SyncWorker.kt | 25 +++++------- .../simplenotes/sync/WebDavSyncService.kt | 38 ++++++++++++++++++ .../ui/editor/NoteEditorViewModel.kt | 26 ++++++------- .../simplenotes/ui/main/MainViewModel.kt | 39 +++++++++---------- .../ui/settings/SettingsViewModel.kt | 14 ++++++- 5 files changed, 91 insertions(+), 51 deletions(-) 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 f30611d..692db55 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 @@ -90,25 +90,20 @@ class SyncWorker( } if (BuildConfig.DEBUG) { - Logger.d(TAG, "📍 Step 2.5: Checking WiFi-only setting") + Logger.d(TAG, "📍 Step 2.5: Checking sync gate (canSync)") } - // 🆕 v1.7.0: WiFi-Only Check (zentral für alle Sync-Arten) - val prefs = applicationContext.getSharedPreferences( - Constants.PREFS_NAME, - Context.MODE_PRIVATE - ) - val wifiOnlySync = prefs.getBoolean( - Constants.KEY_WIFI_ONLY_SYNC, - Constants.DEFAULT_WIFI_ONLY_SYNC - ) - - if (wifiOnlySync && !syncService.isOnWiFi()) { - Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync") - Logger.d(TAG, " User can still manually sync when on WiFi") + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config) + val gateResult = syncService.canSync() + if (!gateResult.canSync) { + if (gateResult.isBlockedByWifiOnly) { + Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync") + } else { + Logger.d(TAG, "⏭️ Sync blocked by gate: ${gateResult.blockReason ?: "offline/no server"}") + } if (BuildConfig.DEBUG) { - Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (WiFi-only skip)") + Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (gate blocked)") Logger.d(TAG, "═══════════════════════════════════════") } 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 3f3778e..5d8dbdb 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 @@ -582,6 +582,44 @@ class WebDavSyncService(private val context: Context) { } } + /** + * 🆕 v1.7.0: Zentrale Sync-Gate Prüfung + * Prüft ALLE Voraussetzungen bevor ein Sync gestartet wird. + * Diese Funktion sollte VOR jedem syncNotes() Aufruf verwendet werden. + * + * @return SyncGateResult mit canSync flag und optionalem Blockierungsgrund + */ + fun canSync(): SyncGateResult { + // 1. Offline Mode Check + if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) { + return SyncGateResult(canSync = false, blockReason = null) // Silent skip + } + + // 2. Server configured? + val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) + if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") { + return SyncGateResult(canSync = false, blockReason = null) // Silent skip + } + + // 3. WiFi-Only Check + val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) + if (wifiOnlySync && !isOnWiFi()) { + return SyncGateResult(canSync = false, blockReason = "wifi_only") + } + + return SyncGateResult(canSync = true, blockReason = null) + } + + /** + * 🆕 v1.7.0: Result-Klasse für canSync() + */ + data class SyncGateResult( + val canSync: Boolean, + val blockReason: String? = null + ) { + val isBlockedByWifiOnly: Boolean get() = blockReason == "wifi_only" + } + suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) { return@withContext try { val sardine = getOrCreateSardine() ?: return@withContext SyncResult( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt index 40da751..3a67d67 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt @@ -355,6 +355,7 @@ class NoteEditorViewModel( /** * Triggers sync after saving a note (if enabled and server configured) * v1.6.0: New configurable sync trigger + * v1.7.0: Uses central canSync() gate for WiFi-only check * * Separate throttling (5 seconds) to prevent spam when saving multiple times */ @@ -365,24 +366,19 @@ class NoteEditorViewModel( return } - // Check 2: Server configured? - val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) - if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") { - Logger.d(TAG, "⏭️ Offline mode - skipping onSave sync") + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) + val syncService = WebDavSyncService(getApplication()) + val gateResult = syncService.canSync() + if (!gateResult.canSync) { + if (gateResult.isBlockedByWifiOnly) { + Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi") + } else { + Logger.d(TAG, "⏭️ onSave sync blocked: ${gateResult.blockReason ?: "offline/no server"}") + } return } - // 🆕 v1.7.0: WiFi-Only Check - val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) - if (wifiOnlySync) { - val syncService = WebDavSyncService(getApplication()) - if (!syncService.isOnWiFi()) { - Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi") - return - } - } - - // Check 3: Throttling (5 seconds) to prevent spam + // Check 2: Throttling (5 seconds) to prevent spam val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0) val now = System.currentTimeMillis() val timeSinceLastSync = now - lastOnSaveSyncTime diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index 44524cd..024ba3e 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -500,23 +500,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** * Trigger manual sync (from toolbar button or pull-to-refresh) + * v1.7.0: Uses central canSync() gate for WiFi-only check */ fun triggerManualSync(source: String = "manual") { - // 🌟 v1.6.0: Block sync in offline mode - if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) { - Logger.d(TAG, "⏭️ $source Sync blocked: Offline mode enabled") - return - } - - // 🆕 v1.7.0: WiFi-Only Check (sofort, kein Netzwerk-Wait) - val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC) - if (wifiOnlySync) { - val syncService = WebDavSyncService(getApplication()) - if (!syncService.isOnWiFi()) { + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) + val syncService = WebDavSyncService(getApplication()) + val gateResult = syncService.canSync() + if (!gateResult.canSync) { + if (gateResult.isBlockedByWifiOnly) { Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi") SyncStateManager.markError(getString(R.string.sync_wifi_only_hint)) - return + } else { + Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}") } + return } // 🆕 v1.7.0: Feedback wenn Sync bereits läuft @@ -536,8 +533,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { try { - val syncService = WebDavSyncService(getApplication()) - // Check for unsynced changes if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") @@ -584,6 +579,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Only runs if server is configured and interval has passed * v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt * v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME + * v1.7.0: Uses central canSync() gate for WiFi-only check */ fun triggerAutoSync(source: String = "auto") { // 🌟 v1.6.0: Check if onResume trigger is enabled @@ -597,10 +593,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } - // Check if server is configured - val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) - if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") { - Logger.d(TAG, "⏭️ Offline mode - skipping onResume sync") + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) + val syncService = WebDavSyncService(getApplication()) + val gateResult = syncService.canSync() + if (!gateResult.canSync) { + if (gateResult.isBlockedByWifiOnly) { + Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: WiFi-only mode, not on WiFi") + } else { + Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: ${gateResult.blockReason ?: "offline/no server"}") + } return } @@ -617,8 +618,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { try { - val syncService = WebDavSyncService(getApplication()) - // Check for unsynced changes if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt index dc5eed8..fbed556 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt @@ -429,9 +429,21 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch { _isSyncing.value = true try { - emitToast(getString(R.string.toast_syncing)) val syncService = WebDavSyncService(getApplication()) + // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung + val gateResult = syncService.canSync() + if (!gateResult.canSync) { + if (gateResult.isBlockedByWifiOnly) { + emitToast(getString(R.string.sync_wifi_only_hint)) + } else { + emitToast(getString(R.string.toast_sync_failed, "Offline mode")) + } + return@launch + } + + emitToast(getString(R.string.toast_syncing)) + if (!syncService.hasUnsyncedChanges()) { emitToast(getString(R.string.toast_already_synced)) return@launch From ebab347d4b3b095dec7844ea60bb6789d6ee84fd Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 26 Jan 2026 23:21:13 +0100 Subject: [PATCH 4/8] fix: Notification opens ComposeMainActivity, WiFi-Only toggle in own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: 1. Notification click now opens ComposeMainActivity instead of legacy MainActivity 2. WiFi-Only toggle moved to its own 'Network Restriction' section at top of sync settings 3. Added hint explaining WiFi-Connect trigger is not affected by WiFi-Only setting UI Changes: - New section header: 'Network Restriction' / 'Netzwerk-Einschränkung' - WiFi-Only toggle now clearly separated from sync triggers - Info card shows when WiFi-Only is enabled explaining the exception --- .../simplenotes/sync/NetworkMonitor.kt | 71 ++++++++++++++----- .../ui/settings/screens/SyncSettingsScreen.kt | 35 ++++++--- .../simplenotes/utils/NotificationHelper.kt | 12 ++-- .../app/src/main/res/values-de/strings.xml | 5 +- android/app/src/main/res/values/strings.xml | 5 +- 5 files changed, 93 insertions(+), 35 deletions(-) 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 f20bfbe..0858d6e 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 @@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) { lastConnectedNetworkId = currentNetworkId - // Auto-Sync check - val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) - Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled") + // WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC! + // Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true + // Aber defensive Prüfung für den Fall, dass Settings sich geändert haben + val wifiConnectEnabled = prefs.getBoolean( + Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, + Constants.DEFAULT_TRIGGER_WIFI_CONNECT + ) + Logger.d(TAG, " WiFi-Connect trigger enabled: $wifiConnectEnabled") - if (autoSyncEnabled) { - Logger.d(TAG, " ✅ Triggering WorkManager...") + if (wifiConnectEnabled) { + Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...") triggerWifiConnectSync() } else { - Logger.d(TAG, " ❌ Auto-sync disabled - not triggering") + Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings") } } else { Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)") @@ -140,23 +145,55 @@ class NetworkMonitor(private val context: Context) { /** * Startet WorkManager mit Network Constraints + NetworkCallback + * + * 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC + * - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync + * - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!) */ fun startMonitoring() { - val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) + Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called") - if (!autoSyncEnabled) { - Logger.d(TAG, "Auto-sync disabled - stopping all monitoring") - stopMonitoring() - return + val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) + val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC) + val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT) + + Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled") + + // 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv) + if (autoSyncEnabled && periodicEnabled) { + Logger.d(TAG, "📅 Starting periodic sync...") + startPeriodicSync() + } else { + WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME) + Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)") } - Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)") + // 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!) + if (wifiConnectEnabled) { + Logger.d(TAG, "📶 Starting WiFi monitoring...") + startWifiMonitoring() + } else { + stopWifiMonitoring() + Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled") + } - // 1. WorkManager für periodic sync - startPeriodicSync() - - // 2. NetworkCallback für WiFi-Connect Detection - startWifiMonitoring() + // 3. Logging für Debug + if (!autoSyncEnabled && !wifiConnectEnabled) { + Logger.d(TAG, "🛑 No background triggers active") + } + } + + /** + * 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor + */ + private fun stopWifiMonitoring() { + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered") + } catch (e: Exception) { + // Already unregistered - das ist OK + Logger.d(TAG, " WiFi callback already unregistered") + } } /** diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt index 2991cb5..d734edf 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt @@ -85,6 +85,31 @@ fun SyncSettingsScreen( Spacer(modifier = Modifier.height(8.dp)) } + // ═══════════════════════════════════════════════════════════════ + // 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger) + // ═══════════════════════════════════════════════════════════════ + + SettingsSectionHeader(text = stringResource(R.string.sync_section_network)) + + // WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect + SettingsSwitch( + title = stringResource(R.string.sync_wifi_only_title), + subtitle = stringResource(R.string.sync_wifi_only_subtitle), + checked = wifiOnlySync, + onCheckedChange = { viewModel.setWifiOnlySync(it) }, + icon = Icons.Default.Wifi, + enabled = isServerConfigured + ) + + // Info-Hinweis dass WiFi-Connect davon ausgenommen ist + if (wifiOnlySync && isServerConfigured) { + SettingsInfoCard( + text = stringResource(R.string.sync_wifi_only_hint) + ) + } + + SettingsDivider() + // ═══════════════════════════════════════════════════════════════ // SOFORT-SYNC Section // ═══════════════════════════════════════════════════════════════ @@ -111,16 +136,6 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - // 🆕 v1.7.0: WiFi-Only Sync Toggle - SettingsSwitch( - title = stringResource(R.string.sync_wifi_only_title), - subtitle = stringResource(R.string.sync_wifi_only_subtitle), - checked = wifiOnlySync, - onCheckedChange = { viewModel.setWifiOnlySync(it) }, - icon = Icons.Default.Wifi, - enabled = isServerConfigured - ) - SettingsDivider() // ═══════════════════════════════════════════════════════════════ 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 b3ab6e5..62d2eda 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 @@ -11,7 +11,7 @@ import android.os.Handler import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import dev.dettmer.simplenotes.MainActivity +import dev.dettmer.simplenotes.ui.main.ComposeMainActivity object NotificationHelper { @@ -58,7 +58,7 @@ object NotificationHelper { * Zeigt Erfolgs-Notification nach Sync */ fun showSyncSuccessNotification(context: Context, syncedCount: Int) { - val intent = Intent(context, MainActivity::class.java).apply { + val intent = Intent(context, ComposeMainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } @@ -154,7 +154,7 @@ object NotificationHelper { * Zeigt Notification bei erkanntem Konflikt */ fun showConflictNotification(context: Context, conflictCount: Int) { - val intent = Intent(context, MainActivity::class.java) + val intent = Intent(context, ComposeMainActivity::class.java) val pendingIntent = PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT @@ -229,7 +229,7 @@ object NotificationHelper { */ fun showSyncSuccess(context: Context, count: Int) { // PendingIntent für App-Öffnung - val intent = Intent(context, MainActivity::class.java).apply { + val intent = Intent(context, ComposeMainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = PendingIntent.getActivity( @@ -260,7 +260,7 @@ object NotificationHelper { */ fun showSyncError(context: Context, message: String) { // PendingIntent für App-Öffnung - val intent = Intent(context, MainActivity::class.java).apply { + val intent = Intent(context, ComposeMainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = PendingIntent.getActivity( @@ -297,7 +297,7 @@ object NotificationHelper { */ fun showSyncWarning(context: Context, hoursSinceLastSync: Long) { // PendingIntent für App-Öffnung - val intent = Intent(context, MainActivity::class.java).apply { + val intent = Intent(context, ComposeMainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = PendingIntent.getActivity( diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 6dce156..81a188d 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -206,10 +206,13 @@ ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag) + 📶 Netzwerk-Einschränkung 📲 Sofort-Sync 📡 Hintergrund-Sync ⚙️ Erweitert + 💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird. + Nach dem Speichern Sync sofort nach jeder Änderung @@ -228,7 +231,7 @@ Sync nur im WLAN Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit. - Sync nur im WLAN möglich + Sync nur im WLAN möglich Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar. Sync ist im Offline-Modus nicht verfügbar. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e94abb8..414911d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -206,10 +206,13 @@ ℹ️ Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day) + 📶 Network Restriction 📲 Instant Sync 📡 Background Sync ⚙️ Advanced + 💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected. + After Saving Sync immediately after each change @@ -228,7 +231,7 @@ WiFi-only sync Sync only when connected to WiFi. Saves mobile data and prevents long wait times. - Sync only works when connected to WiFi + Sync only works when connected to WiFi Manual sync (toolbar/pull-to-refresh) is also available. Sync is not available in offline mode. From 5135c711a5f79a669f9ab63027893b68d17b7d02 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 26 Jan 2026 23:25:13 +0100 Subject: [PATCH 5/8] chore: Suppress SwallowedException in stopWifiMonitoring() The exception is intentionally swallowed - it's OK if the callback is already unregistered. --- .../src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt | 1 + 1 file changed, 1 insertion(+) 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 0858d6e..76f6829 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 @@ -186,6 +186,7 @@ class NetworkMonitor(private val context: Context) { /** * 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor */ + @Suppress("SwallowedException") private fun stopWifiMonitoring() { try { connectivityManager.unregisterNetworkCallback(networkCallback) From 6dba091c03eb809cab3c704b2ba2baaa387b907a Mon Sep 17 00:00:00 2001 From: inventory69 Date: Tue, 27 Jan 2026 13:19:12 +0100 Subject: [PATCH 6/8] Unify and streamline documentation, changelogs, and app descriptions (DE/EN). Improved clarity, removed redundancies, and updated feature highlights for v1.7.0. [skip ci] --- .github/workflows/build-production-apk.yml | 68 ++++----- README.de.md | 129 ++++++++++-------- README.md | 102 +++++++------- .../metadata/android/de-DE/changelogs/17.txt | 15 +- .../android/de-DE/full_description.txt | 72 +++------- .../metadata/android/en-US/changelogs/17.txt | 15 +- .../android/en-US/full_description.txt | 72 +++------- 7 files changed, 206 insertions(+), 267 deletions(-) diff --git a/.github/workflows/build-production-apk.yml b/.github/workflows/build-production-apk.yml index 2a76048..e742e22 100644 --- a/.github/workflows/build-production-apk.yml +++ b/.github/workflows/build-production-apk.yml @@ -2,11 +2,11 @@ name: Build Android Production APK on: push: - branches: [ main ] # Nur bei Push/Merge auf main triggern - workflow_dispatch: # Ermöglicht manuellen Trigger + branches: [ main ] # Only trigger on push/merge to main + workflow_dispatch: # Enables manual trigger permissions: - contents: write # Fuer Release-Erstellung erforderlich + contents: write # Required for release creation jobs: build: @@ -14,50 +14,50 @@ jobs: runs-on: ubuntu-latest steps: - - name: Code auschecken + - name: Checkout code uses: actions/checkout@v4 - - name: Java einrichten + - name: Setup Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - - name: Semantic Versionsnummer aus build.gradle.kts extrahieren + - name: Extract semantic version from build.gradle.kts run: | - # Version aus build.gradle.kts fuer F-Droid Kompatibilität + # Version from build.gradle.kts for F-Droid compatibility VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/') VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/') - # Semantische Versionierung (nicht datums-basiert) + # Semantic versioning (not date-based) BUILD_NUMBER="$VERSION_CODE" echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV - echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)" + echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)" - - name: Version aus build.gradle.kts verifizieren + - name: Verify version from build.gradle.kts run: | - echo "✅ Verwende Version aus build.gradle.kts:" + echo "✅ Using version from build.gradle.kts:" grep -E "versionCode|versionName" android/app/build.gradle.kts - - name: Android Signing konfigurieren + - name: Configure Android signing run: | echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties echo "storeFile=simple-notes-release.jks" >> android/key.properties - echo "✅ Signing-Konfiguration erstellt" + echo "✅ Signing configuration created" - - name: Produktions-APK bauen (Standard + F-Droid Flavors) + - name: Build production APK (Standard + F-Droid Flavors) run: | cd android ./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace - - name: APK-Varianten mit Versionsnamen kopieren + - name: Copy APK variants with version names run: | mkdir -p apk-output @@ -69,34 +69,34 @@ jobs: cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \ apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk - echo "✅ APK-Dateien vorbereitet:" + echo "✅ APK files prepared:" ls -lh apk-output/ - - name: APK-Artefakte hochladen + - name: Upload APK artifacts uses: actions/upload-artifact@v4 with: name: simple-notes-sync-apks-v${{ env.VERSION_NAME }} path: apk-output/*.apk - retention-days: 90 # Produktions-Builds länger aufbewahren + retention-days: 90 # Keep production builds longer - - name: Commit-Informationen auslesen + - name: Extract commit information run: | echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV - - name: F-Droid Changelogs lesen + - name: Read F-Droid changelogs run: | - # Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting + # Read German changelog (main language) - Use printf to ensure proper formatting if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt") echo "CHANGELOG_DE<> $GITHUB_ENV echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV echo "GHADELIMITER" >> $GITHUB_ENV else - echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV + echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV fi - # Lese englische Changelog (optional) + # Read English changelog (optional) if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt") echo "CHANGELOG_EN<> $GITHUB_ENV @@ -127,25 +127,19 @@ jobs: - --- - ## 📦 Downloads - | Variante | Datei | Info | - |----------|-------|------| - | **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) | - | F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store | - - --- + | Variant | File | Info | + |---------|------|------| + | **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) | + | F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store | - ## 📊 Build-Info + ## 📊 Build Info - **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }}) - - **Datum:** ${{ env.COMMIT_DATE }} + - **Date:** ${{ env.COMMIT_DATE }} - **Commit:** ${{ env.SHORT_SHA }} - --- - ## 🔐 APK Signature Verification All APKs are signed with the official release certificate. @@ -157,8 +151,6 @@ jobs: 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A ``` - --- - - **[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)** + **[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)** env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.de.md b/README.de.md index e496e4a..80a09e9 100644 --- a/README.de.md +++ b/README.de.md @@ -1,63 +1,84 @@
- -# Simple Notes Sync - -**Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server** - -[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) -[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) -![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white) -[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) -[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) - -[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) -[Get it on Obtainium](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync) -[Get it on F-Droid](https://f-droid.org/packages/dev.dettmer.simplenotes/) - -[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Quick Start](QUICKSTART.de.md) - -**🌍** **Deutsch** · [English](README.md) - +
+Logo +

---- +

Simple Notes Sync

+ +

Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.

+ + + +

+ + Get it on IzzyOnDroid + + + Get it on Obtainium + + + Get it on F-Droid + +

+
+SHA-256 Hash des Signaturzertifikats:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A +
+ +
+ +
[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)
+**🌍** Deutsch · [English](README.md) + +
## 📱 Screenshots

- Sync-Status - Notiz bearbeiten - Checkliste bearbeiten - Einstellungen - Server-Einstellungen - Sync-Einstellungen + Sync status + Edit note + Edit checklist + Settings + Server settings + Sync settings

---- -
-📝 Offline-first  •  🔄 Smart Sync  •  🔒 Self-hosted  •  🔋 Akkuschonend + 📝 Offline-first  •  🔄 Smart Sync  •  🔒 Self-hosted  •  🔋 Akkuschonend
---- - ## ✨ Highlights -- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop -- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl -- 📝 **Offline-First** - Funktioniert ohne Internet -- 🔄 **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot -- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV) -- 💾 **Lokales Backup** - Export/Import als JSON-Datei -- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora -- 🔋 **Akkuschonend** - ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync -- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors +- 📝 **Offline-first** – Funktioniert ohne Internet +- 📊 **Flexible Ansichten** – Listen- und Grid-Layout +- ✅ **Checklisten** – Tap-to-Check, Drag & Drop +- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl +- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot +- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV) +- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt) +- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora +- 🔋 **Akkuschonend** – ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync +- 🎨 **Material Design 3** – Dynamischer Dark/Light Mode & Farben -➡️ **Vollständige Feature-Liste:** [FEATURES.de.md](docs/FEATURES.de.md) - ---- +➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md) ## 🚀 Schnellstart @@ -78,17 +99,15 @@ docker compose up -d 1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest) 2. Installieren & öffnen 3. ⚙️ Einstellungen → Server konfigurieren: - - **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_ - - **User:** `noteuser` - - **Passwort:** _(aus .env)_ - - **WLAN:** _(dein Netzwerk-Name)_ + - **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_ + - **User:** `noteuser` + - **Passwort:** _(aus .env)_ + - **WLAN:** _(dein Netzwerk-Name)_ 4. **Verbindung testen** → Auto-Sync aktivieren 5. Fertig! 🎉 ➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md) ---- - ## 📚 Dokumentation | Dokument | Inhalt | @@ -103,8 +122,6 @@ docker compose up -d | **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 | | **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 | ---- - ## 🛠️ Entwicklung ```bash @@ -112,21 +129,15 @@ cd android ./gradlew assembleStandardRelease ``` -➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment) - ---- +➡️ **Build-Anleitung:** [docs/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment) ## 🤝 Contributing Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) ---- - ## 📄 Lizenz -MIT License - siehe [LICENSE](LICENSE) - ---- +MIT License – siehe [LICENSE](LICENSE)
diff --git a/README.md b/README.md index 6f381c1..c72d914 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,53 @@ +
+
+Logo +

+
+ +

Simple Notes Sync

+ +

Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.

+ + + +

+ + Get it on IzzyOnDroid + + + Get it on Obtainium + + + Get it on F-Droid + +

+
+SHA-256 hash of the signing certificate:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A +
+
-# Simple Notes Sync - -**Minimalist offline notes with auto-sync to your own server** - -[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) -[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) -![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white) -[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) -[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) - -[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) -[Get it on Obtainium](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync) -[Get it on F-Droid](https://f-droid.org/packages/dev.dettmer.simplenotes/) - - - -[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md) - +
[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)
**🌍** [Deutsch](README.de.md) · **English**
---- - ## 📱 Screenshots

@@ -35,32 +59,27 @@ Sync settings

---- -
-📝 Offline-first  •  🔄 Smart Sync  •  🔒 Self-hosted  •  🔋 Battery-friendly + 📝 Offline-first  •  🔄 Smart Sync  •  🔒 Self-hosted  •  🔋 Battery-friendly
---- - ## ✨ Highlights -- ✅ **NEW: Checklists** - Tap-to-check, drag & drop -- 🌍 **NEW: Multilingual** - English/German with language selector - 📝 **Offline-first** - Works without internet +- 📊 **Flexible views** - Switch between list and grid layout +- ✅ **Checklists** - Tap-to-check, drag & drop +- 🌍 **Multilingual** - English/German with language selector - 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot - 🔒 **Self-hosted** - Your data stays with you (WebDAV) -- 💾 **Local backup** - Export/Import as JSON file +- 💾 **Local backup** - Export/Import as JSON file (encryption available) - 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora - 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync -- 🎨 **Material Design 3** - Dark mode & dynamic colors +- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings ➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md) ---- - ## 🚀 Quick Start ### 1. Server Setup (5 minutes) @@ -89,21 +108,6 @@ docker compose up -d ➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md) ---- - -## 🔐 APK Verification - -All official releases are signed with the same certificate. - -**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app) - -**Expected SHA-256:** -``` -42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A -``` - ---- - ## 📚 Documentation | Document | Content | @@ -125,20 +129,14 @@ cd android ➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment) ---- - ## 🤝 Contributing Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) ---- - ## 📄 License MIT License - see [LICENSE](LICENSE) ---- -
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3 diff --git a/fastlane/metadata/android/de-DE/changelogs/17.txt b/fastlane/metadata/android/de-DE/changelogs/17.txt index 41241a1..9f8578e 100644 --- a/fastlane/metadata/android/de-DE/changelogs/17.txt +++ b/fastlane/metadata/android/de-DE/changelogs/17.txt @@ -1,8 +1,7 @@ -• Grid-Ansicht: Pinterest-Style Layout ohne Lücken -• Grid-Ansicht: Konsistente Abstände, Scroll-Position bleibt erhalten -• Nur-WLAN Sync Toggle in Einstellungen -• VPN-Unterstützung: Sync funktioniert korrekt bei aktivem VPN -• Server-Wechsel: Sync-Status wird für alle Notizen zurückgesetzt -• Self-signed SSL: Dokumentation für eigene Zertifikate -• Schnellere Server-Prüfung (1s Timeout) -• "Sync läuft bereits" Feedback +• Neu: Raster-Ansicht - Danke an freemen +• Neu: Nur-WLAN Sync Toggle in Einstellungen +• Neu: Verschlüsselung bei lokalen Backups - Danke an @SilentCoderHere (#9) +• Behoben: Sync funktioniert korrekt bei aktivem VPN - Danke an @roughnecks (#11) +• Verbessert: Server-Wechsel - Sync-Status wird für alle Notizen zurückgesetzt +• Verbessert: "Sync läuft bereits" Feedback bei weiteren Ausführungen +• Verschiedene Fixes und UI-Verbesserungen diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 50e7a90..3a6dc8d 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,62 +1,32 @@ -Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation. +Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features. -HAUPTFUNKTIONEN: - -• Text-Notizen und Checklisten erstellen -• Checklisten mit Tap-to-Check und Drag & Drop -• Auswahlmodus: Long-Press zur Mehrfachauswahl für Batch-Aktionen -• WebDAV-Synchronisation mit eigenem Server +Hauptfunktionen: +• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop) +• NEU: Raster-Ansicht (Grid View) für Notizen • Multi-Device Sync (Handy, Tablet, Desktop) -• Markdown-Export für Obsidian/Desktop-Editoren -• Checklisten als GitHub-Style Task-Listen exportieren -• Automatische Synchronisation im Heim-WLAN -• Konfigurierbares Sync-Interval (15/30/60 Minuten) -• Material Design 3 mit Dynamic Colors (Android 12+) -• Jetpack Compose UI - modern, schnell und flüssig +• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.) +• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code) +• NEU: WiFi-only Sync, VPN-Unterstützung, Verschlüsselung für lokale Backups +• Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot • Komplett offline nutzbar • Keine Werbung, keine Tracker -MEHRSPRACHIG: +Datenschutz & Sicherheit: +• Alle Daten bleiben bei dir – keine Cloud, keine Tracking-Bibliotheken +• Unterstützung für selbstsignierte SSL-Zertifikate (Self-signed SSL) +• SHA-256 Hash des Signaturzertifikats in App und Releases sichtbar -• Englische und deutsche Sprachunterstützung -• Per-App Sprachauswahl (Android 13+) -• Automatische Systemsprachen-Erkennung -• Über 400 übersetzte Strings +Synchronisation: +• Automatisch oder manuell, optimierte Performance, periodischer Sync optional +• Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen -DATENSCHUTZ: +UI & Design: +• Moderne Jetpack Compose Oberfläche +• Material Design 3, Dynamic Colors, Dark Mode +• Animationen und Live Sync-Status -Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools. - -MULTI-DEVICE SYNC: - -• Notizen synchronisieren automatisch zwischen allen Geräten -• Lösch-Tracking verhindert "Zombie-Notizen" -• Intelligente Konfliktlösung durch Timestamps -• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.) -• Änderungen von Desktop-Editoren werden automatisch importiert - -SYNCHRONISATION: - -• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.) -• Konfigurierbare Sync-Trigger: Wähle einzeln, wann synchronisiert wird -• 5 Trigger: onSave (nach dem Speichern), onResume (beim Öffnen), WiFi-Connect, Periodic (15/30/60 Min), Boot -• Offline-Modus: Alle Netzwerkfunktionen mit einem Schalter deaktivieren -• Smarte Defaults: nur ereignisbasierte Trigger aktiv (~0.2%/Tag Akku) -• Periodischer Sync optional (Standard: AUS) -• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit) -• E-Tag Caching für 20x schnellere "keine Änderungen" Checks -• Silent-Sync Modus: kein Banner bei Auto-Sync -• Doze Mode optimiert für zuverlässige Background-Syncs -• Manuelle Synchronisation jederzeit möglich - -MATERIAL DESIGN 3: - -• Moderne Jetpack Compose Benutzeroberfläche -• Dynamic Colors (Material You) auf Android 12+ -• Dark Mode Support -• Auswahlmodus mit Batch-Löschen -• Live Sync-Status Anzeige -• Flüssige Slide-Animationen +Mehrsprachig: +• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl Open Source unter MIT-Lizenz Quellcode: https://github.com/inventory69/simple-notes-sync \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt index 7304eb1..addfb61 100644 --- a/fastlane/metadata/android/en-US/changelogs/17.txt +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -1,8 +1,7 @@ -• Grid view: Pinterest-style layout without gaps -• Grid view: Consistent spacing, scroll position preserved -• WiFi-only sync toggle in settings -• VPN support: Sync works correctly when VPN is active -• Server change: Sync status resets to pending for all notes -• Self-signed SSL: Documentation for custom certificates -• Faster server check (1s timeout) -• "Sync already running" feedback +• New: Grid view - Thanks to freemen +• New: WiFi-only sync toggle in settings +• New: Encryption for local backups - Thanks to @SilentCoderHere (#9) +• Fixed: Sync works correctly when VPN is active - Thanks to @roughnecks (#11) +• Improved: Server change - Sync status resets for all notes +• Improved: "Sync already running" feedback for additional executions +• Various fixes and UI improvements diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6f8ca29..166ee44 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,62 +1,32 @@ -Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization. +Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features. -KEY FEATURES: - -• Create text notes and checklists -• Checklists with tap-to-check, drag & drop reordering -• Selection mode: long-press to select multiple notes for batch actions -• WebDAV synchronization with your own server +Key Features: +• Text notes and checklists (tap-to-check, drag & drop) +• NEW: Grid view for notes • Multi-device sync (phone, tablet, desktop) -• Markdown export for Obsidian/desktop editors -• Checklists export as GitHub-style task lists -• Automatic synchronization on home WiFi -• Configurable sync interval (15/30/60 minutes) -• Material Design 3 with Dynamic Colors (Android 12+) -• Jetpack Compose UI - modern, fast, and smooth +• WebDAV sync with your own server (Nextcloud, ownCloud, etc.) +• Markdown export/import for desktop editors (Obsidian, VS Code) +• NEW: WiFi-only sync, VPN support, encryption for local backups +• Configurable sync triggers: onSave, onResume, WiFi, periodic, boot • Fully usable offline • No ads, no trackers -MULTILINGUAL: +Privacy & Security: +• Your data stays with you – no cloud, no tracking libraries +• Support for self-signed SSL certificates +• SHA-256 hash of signing certificate shown in app and releases -• English and German language support -• Per-App Language selector (Android 13+) -• Automatic system language detection -• 400+ translated strings +Synchronization: +• Automatic or manual, optimized performance, optional periodic sync +• Smart conflict resolution, deletion tracking, batch actions -PRIVACY: +UI & Design: +• Modern Jetpack Compose interface +• Material Design 3, dynamic colors, dark mode +• Animations and live sync status -Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools. - -MULTI-DEVICE SYNC: - -• Notes sync automatically between all your devices -• Deletion tracking prevents "zombie notes" -• Smart conflict resolution through timestamps -• Markdown files for desktop editing (Obsidian, VS Code, etc.) -• Changes from desktop editors are auto-imported - -SYNCHRONIZATION: - -• Supports all WebDAV servers (Nextcloud, ownCloud, etc.) -• Configurable Sync Triggers: Choose individually when to sync -• 5 triggers: onSave (after saving), onResume (on open), WiFi-Connect, Periodic (15/30/60 min), Boot -• Offline Mode: Disable all network features with one switch -• Smart defaults: event-driven triggers only (~0.2%/day battery) -• Periodic sync optional (default: OFF) -• Optimized performance: skips unchanged files (~2-3s sync time) -• E-Tag caching for 20x faster "no changes" checks -• Silent-Sync mode: no banner during auto-sync -• Doze Mode optimized for reliable background syncs -• Manual synchronization available anytime - -MATERIAL DESIGN 3: - -• Modern Jetpack Compose user interface -• Dynamic Colors (Material You) on Android 12+ -• Dark Mode support -• Selection mode with batch delete -• Live sync status indicator -• Smooth slide animations +Multilingual: +• English and German, automatic detection, in-app language selector Open Source under MIT License Source code: https://github.com/inventory69/simple-notes-sync From c536ad31775a3920ea13fbb361d7b4d4045205ab Mon Sep 17 00:00:00 2001 From: inventory69 Date: Tue, 27 Jan 2026 13:30:17 +0100 Subject: [PATCH 7/8] fix: badges aligned and underline removed [skip ci] --- README.de.md | 16 ++++++++-------- README.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.de.md b/README.de.md index 80a09e9..945fffd 100644 --- a/README.de.md +++ b/README.de.md @@ -1,7 +1,6 @@

Logo -

Simple Notes Sync

@@ -27,16 +26,17 @@

- - Get it on IzzyOnDroid +

SHA-256 Hash des Signaturzertifikats:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
@@ -140,7 +140,7 @@ Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) MIT License – siehe [LICENSE](LICENSE)
- +

**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
diff --git a/README.md b/README.md index c72d914..13ee5c1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@

Logo -

Simple Notes Sync

@@ -27,16 +26,17 @@

- - Get it on IzzyOnDroid +

SHA-256 hash of the signing certificate:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
@@ -138,7 +138,7 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) MIT License - see [LICENSE](LICENSE)
- +

**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
From 91beee0f8b1d99a8a1d3bf35e7f5497fc6dac62f Mon Sep 17 00:00:00 2001 From: inventory69 Date: Tue, 27 Jan 2026 13:53:44 +0100 Subject: [PATCH 8/8] docs: fix badge layout finally [skip ci] --- README.de.md | 50 +++++++++++++++++++++++--------------------------- README.md | 50 +++++++++++++++++++++++--------------------------- 2 files changed, 46 insertions(+), 54 deletions(-) diff --git a/README.de.md b/README.de.md index 945fffd..c75af29 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,4 @@
-
Logo
@@ -8,35 +7,32 @@

Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.

- - Android - - - Kotlin - - - Jetpack Compose - - - Material 3 - - - License - + +[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) +[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) +[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/) +[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) +[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) +
-

-

- - Get it on IzzyOnDroid - - - Get it on Obtainium - - - Get it on F-Droid - +
+ + + + + + + + + + +
+
SHA-256 Hash des Signaturzertifikats:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
diff --git a/README.md b/README.md index 13ee5c1..279a33c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@
-
Logo
@@ -8,35 +7,32 @@

Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.

- - Android - - - Kotlin - - - Jetpack Compose - - - Material 3 - - - License - + +[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) +[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) +[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/) +[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) +[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) +
-

-

- - Get it on IzzyOnDroid - - - Get it on Obtainium - - - Get it on F-Droid - +
+ + + + + + + + + + +
+
SHA-256 hash of the signing certificate:
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A