debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support

This commit is contained in:
inventory69
2026-01-26 21:19:46 +01:00
parent 217a174478
commit b70bc4d8f6
42 changed files with 2491 additions and 60 deletions

87
.github/workflows/build-debug-apk.yml vendored Normal file
View File

@@ -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

View File

@@ -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 }}

1
.gitignore vendored
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)
<div align="center">
**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -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)
<div align="center">
**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -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
// ═══════════════════════════════════════════════════════════════════════

View File

@@ -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
*/

View File

@@ -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)

View File

@@ -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

View File

@@ -124,6 +124,26 @@ class NotesStorage(private val context: Context) {
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
}

View File

@@ -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(

View File

@@ -83,6 +83,11 @@ fun NoteEditorScreen(
var focusNewItemId by remember { mutableStateOf<String?>(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)
}

View File

@@ -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(

View File

@@ -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) {
if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
listState.animateScrollToItem(0)
}
viewModel.resetScrollToTop()
}
}
@@ -176,6 +188,27 @@ fun MainScreen(
// Content: Empty state or notes list
if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f))
} else {
// 🎨 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,
@@ -195,6 +228,7 @@ fun MainScreen(
)
}
}
}
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
AnimatedVisibility(

View File

@@ -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<String> = _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
}

View File

@@ -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(

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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<Note>,
gridState: LazyStaggeredGridState,
showSyncStatus: Boolean,
selectedNoteIds: Set<String>,
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) }
)
}
}
}

View File

@@ -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() }
)
}
}
}

View File

@@ -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
}

View File

@@ -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<Boolean> = _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<Boolean> = _wifiOnlySync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════
@@ -173,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
val fileLoggingEnabled: StateFlow<Boolean> = _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<String> = _displayMode.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI State
// ═══════════════════════════════════════════════════════════════════════
@@ -216,39 +238,128 @@ 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)")
}
}
/**
* <20> 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() {
@@ -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")
}
}

View File

@@ -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<String?>(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)
}
}
}

View File

@@ -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<Uri?>(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<Uri?>(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 -> {

View File

@@ -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)

View File

@@ -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))
}
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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()
// ═══════════════════════════════════════════════════════════════

View File

@@ -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
}

View File

@@ -23,6 +23,7 @@
<!-- ============================= -->
<!-- EMPTY STATE -->
<!-- ============================= -->
<string name="empty_state_emoji">📝</string>
<string name="empty_state_title">Noch keine Notizen</string>
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
@@ -196,11 +197,11 @@
<string name="sync_interval_section">Sync-Intervall</string>
<string name="sync_interval_info">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.</string>
<string name="sync_interval_15min_title">⚡ Alle 15 Minuten</string>
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)</string>
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8%% Akku/Tag (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Alle 30 Minuten (Empfohlen)</string>
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)</string>
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4%% Akku/Tag (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Alle 60 Minuten</string>
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)</string>
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt)</string>
<!-- Legacy -->
<string name="auto_sync_info"> 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)</string>
@@ -224,6 +225,11 @@
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">Sync nur im WLAN</string>
<string name="sync_wifi_only_subtitle">Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit.</string>
<string name="sync_wifi_only_hint">Sync nur im WLAN möglich</string>
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
@@ -253,6 +259,20 @@
<string name="backup_local_section">Lokales Backup</string>
<string name="backup_create">💾 Backup erstellen</string>
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
<!-- 🔐 v1.7.0: Verschlüsselung -->
<string name="backup_encryption_title">Backup verschlüsseln</string>
<string name="backup_encryption_subtitle">Schütze deine Backup-Datei mit Passwort</string>
<string name="backup_encryption_password">Passwort</string>
<string name="backup_encryption_password_hint">Passwort eingeben (min. 8 Zeichen)</string>
<string name="backup_encryption_confirm">Passwort bestätigen</string>
<string name="backup_encryption_confirm_hint">Passwort erneut eingeben</string>
<string name="backup_encryption_error_mismatch">Passwörter stimmen nicht überein</string>
<string name="backup_encryption_error_too_short">Passwort zu kurz (min. 8 Zeichen)</string>
<string name="backup_decryption_required">🔒 Verschlüsseltes Backup</string>
<string name="backup_decryption_password">Passwort zum Entschlüsseln eingeben</string>
<string name="backup_decryption_error">❌ Entschlüsselung fehlgeschlagen. Falsches Passwort?</string>
<string name="backup_server_section">Server-Backup</string>
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
@@ -308,6 +328,15 @@
<string name="language_info"> Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
<!-- ============================= -->
<!-- SETTINGS - DISPLAY -->
<!-- ============================= -->
<string name="display_settings_title">Anzeige</string>
<string name="display_mode_title">Notizen-Ansicht</string>
<string name="display_mode_list">📋 Listen-Ansicht</string>
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein.</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
@@ -357,6 +386,8 @@
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
<!-- 🔄 v1.7.0: Server change notification -->
<string name="toast_server_changed_sync_reset">🔄 Server geändert. %d Notizen werden beim nächsten Sync hochgeladen.</string>
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>

View File

@@ -197,11 +197,11 @@
<string name="sync_interval_section">Sync Interval</string>
<string name="sync_interval_info">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.</string>
<string name="sync_interval_15min_title">⚡ Every 15 minutes</string>
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8% battery/day (~23 mAh)</string>
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8%% battery/day (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Every 30 minutes (Recommended)</string>
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4% battery/day (~12 mAh)</string>
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4%% battery/day (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Every 60 minutes</string>
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2% battery/day (~6 mAh est.)</string>
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2%% battery/day (~6 mAh est.)</string>
<!-- Legacy -->
<string name="auto_sync_info"> 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)</string>
@@ -225,6 +225,11 @@
<string name="sync_trigger_boot_title">After Device Restart</string>
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">WiFi-only sync</string>
<string name="sync_wifi_only_subtitle">Sync only when connected to WiFi. Saves mobile data and prevents long wait times.</string>
<string name="sync_wifi_only_hint">Sync only works when connected to WiFi</string>
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
@@ -254,6 +259,20 @@
<string name="backup_local_section">Local Backup</string>
<string name="backup_create">💾 Create Backup</string>
<string name="backup_restore_file">📂 Restore from File</string>
<!-- 🔐 v1.7.0: Encryption -->
<string name="backup_encryption_title">Encrypt Backup</string>
<string name="backup_encryption_subtitle">Password-protect your backup file</string>
<string name="backup_encryption_password">Password</string>
<string name="backup_encryption_password_hint">Enter password (min. 8 characters)</string>
<string name="backup_encryption_confirm">Confirm Password</string>
<string name="backup_encryption_confirm_hint">Re-enter password</string>
<string name="backup_encryption_error_mismatch">Passwords don\'t match</string>
<string name="backup_encryption_error_too_short">Password too short (min. 8 characters)</string>
<string name="backup_decryption_required">🔒 Encrypted Backup</string>
<string name="backup_decryption_password">Enter password to decrypt</string>
<string name="backup_decryption_error">❌ Decryption failed. Wrong password?</string>
<string name="backup_server_section">Server Backup</string>
<string name="backup_restore_server">☁️ Restore from Server</string>
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
@@ -309,6 +328,15 @@
<string name="language_info"> Choose your preferred language. The app will restart to apply the change.</string>
<string name="language_changed_restart">Language changed. Restarting…</string>
<!-- ============================= -->
<!-- SETTINGS - DISPLAY -->
<!-- ============================= -->
<string name="display_settings_title">Display</string>
<string name="display_mode_title">Note Display Mode</string>
<string name="display_mode_list">📋 List View</string>
<string name="display_mode_grid">🎨 Grid View</string>
<string name="display_mode_info">Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width.</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
@@ -358,6 +386,8 @@
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
<string name="toast_logs_delete_error">❌ Error deleting: %s</string>
<!-- 🔄 v1.7.0: Server change notification -->
<string name="toast_server_changed_sync_reset">🔄 Server changed. %d notes will be uploaded on next sync.</string>
<string name="toast_link_error">❌ Error opening link</string>
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
<string name="toast_file_logging_disabled">📝 File logging disabled</string>

View File

@@ -11,6 +11,8 @@
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}

116
docs/DEBUG_APK.md Normal file
View File

@@ -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.** 🙏

166
docs/SELF_SIGNED_SSL.md Normal file
View File

@@ -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
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" /> <!-- ← Enables self-signed support -->
</trust-anchors>
</base-config>
```
---
## 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)

View File

@@ -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

View File

@@ -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