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