mid commit

This commit is contained in:
2026-02-18 00:45:02 +00:00
parent 5764e8c0ec
commit 7ddad7e5e7
15 changed files with 588 additions and 540 deletions
+10
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
alias(libs.plugins.ksp)
} }
import java.util.Properties import java.util.Properties
@@ -162,6 +163,15 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Homescreen Widgets // 🆕 v1.8.0: Homescreen Widgets
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -36,8 +36,6 @@ import android.widget.CheckBox
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -6,6 +6,10 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class SimpleNotesApplication : Application() { class SimpleNotesApplication : Application() {
@@ -30,6 +34,12 @@ class SimpleNotesApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startKoin {
androidLogger() // Log Koin events
androidContext(this@SimpleNotesApplication) // Provide context to modules
modules(appModule)
}
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization // 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
@@ -0,0 +1,33 @@
package dev.dettmer.simplenotes.di
import android.content.Context
import androidx.room.Room
import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"notes_database"
).build()
}
single { get<AppDatabase>().noteDao() }
single { get<AppDatabase>().deletedNoteDao() }
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
single { NotesStorage(androidContext(), get()) }
viewModel { MainViewModel(get(), get()) }
}
@@ -0,0 +1,14 @@
package dev.dettmer.simplenotes.storage
import androidx.room.Database
import androidx.room.RoomDatabase
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
}
@@ -1,76 +1,58 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class NotesStorage(private val context: Context) { class NotesStorage(
private val context: Context,
database: AppDatabase
) {
companion object { companion object {
private const val TAG = "NotesStorage" private const val TAG = "NotesStorage"
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
private val deletionTrackerMutex = Mutex()
} }
private val notesDir: File = File(context.filesDir, "notes").apply { private val noteDao = database.noteDao()
if (!exists()) mkdirs() private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) {
noteDao.saveNote(note)
} }
fun saveNote(note: Note) { suspend fun loadNote(id: String): NoteEntity? {
val file = File(notesDir, "${note.id}.json") return noteDao.getNote(id)
file.writeText(note.toJson())
} }
fun loadNote(id: String): Note? { suspend fun loadAllNotes(): List<NoteEntity> {
val file = File(notesDir, "$id.json") return noteDao.getAllNotes()
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
}
} }
/** suspend fun deleteNote(id: String): Boolean {
* Lädt alle Notizen aus dem lokalen Speicher. val deletedRows = noteDao.deleteNoteById(id)
*
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
* damit der User die Sortierung konfigurieren kann.
*/
fun loadAllNotes(): List<Note> {
return notesDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?: emptyList()
}
fun deleteNote(id: String): Boolean { if (deletedRows > 0) {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id") Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId) trackDeletionSafe(id, deviceId)
return true
}
return false
} }
return deleted suspend fun deleteAllNotes(): Boolean {
}
fun deleteAllNotes(): Boolean {
return try { return try {
val notes = loadAllNotes() val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) { // Batch tracking and deleting
deleteNote(note.id) // Uses trackDeletion() automatically notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
} }
noteDao.deleteAllNotes()
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)") Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true true
@@ -82,101 +64,28 @@ class NotesStorage(private val context: Context) {
// === Deletion Tracking === // === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
/**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
*
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker.
*
* @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung
*/
suspend fun trackDeletionSafe(noteId: String, deviceId: String) { suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
deletionTrackerMutex.withLock { // Room handles internal transactions and thread-safety natively.
val tracker = loadDeletionTracker() // The Mutex is no longer required.
tracker.addDeletion(noteId, deviceId) deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
}
}
/**
* Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
*
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId") Logger.d(TAG, "📝 Tracked deletion: $noteId")
} }
fun isNoteDeleted(noteId: String): Boolean { suspend fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker() return deletedNoteDao.isNoteDeleted(noteId)
return tracker.isDeleted(noteId)
} }
fun clearDeletionTracker() { suspend fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker()) deletedNoteDao.clearTracker()
Logger.d(TAG, "🗑️ Deletion tracker cleared") Logger.d(TAG, "🗑️ Deletion tracker cleared")
} }
/** suspend fun resetAllSyncStatusToPending(): Int {
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes val updatedCount = noteDao.updateSyncStatus(
* This ensures notes are uploaded to the new server on next sync oldStatus = SyncStatus.SYNCED,
*/ newStatus = SyncStatus.PENDING
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") Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount return updatedCount
} }
fun getNotesDir(): File = notesDir
} }
@@ -0,0 +1,19 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
@Dao
interface DeletedNoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun trackDeletion(deletedNote: DeletedNoteEntity)
@Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)")
suspend fun isNoteDeleted(noteId: String): Boolean
@Query("DELETE FROM deleted_notes")
suspend fun clearTracker()
}
@@ -0,0 +1,29 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNote(note: NoteEntity)
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getNote(id: String): NoteEntity?
@Query("SELECT * FROM notes")
suspend fun getAllNotes(): List<NoteEntity>
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteNoteById(id: String): Int
@Query("DELETE FROM notes")
suspend fun deleteAllNotes(): Int
@Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus")
suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int
}
@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "deleted_notes")
data class DeletedNoteEntity(
@PrimaryKey val noteId: String,
val deviceId: String,
val deletedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val timestamp: Long,
val syncStatus: SyncStatus
)
@@ -44,6 +44,7 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/** /**
* Main Activity with Jetpack Compose UI * Main Activity with Jetpack Compose UI
@@ -65,7 +66,7 @@ class ComposeMainActivity : ComponentActivity() {
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
} }
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModel()
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
@@ -59,6 +59,7 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
@@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel = koinViewModel(),
onOpenNote: (String?) -> Unit, onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit onCreateNote: (NoteType) -> Unit
@@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection import dev.dettmer.simplenotes.models.SortDirection
@@ -34,7 +36,10 @@ import kotlinx.coroutines.withContext
* *
* Manages notes list, sync state, and deletion with undo. * Manages notes list, sync state, and deletion with undo.
*/ */
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(
private val storage: NotesStorage,
private val prefs: SharedPreferences
) : ViewModel() {
companion object { companion object {
private const val TAG = "MainViewModel" private const val TAG = "MainViewModel"
@@ -42,9 +47,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
} }
private val storage = NotesStorage(application)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Notes State // Notes State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -207,7 +209,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadNotesAsync() { private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds } val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
id = it.id,
content = it.content
) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top // Phase 3: Detect if a new note was added at the top
@@ -1,17 +0,0 @@
package dev.dettmer.simplenotes
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+12
View File
@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
navigationCompose = "2.7.6" navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0" lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2" activityCompose = "1.8.2"
room = "2.6.1"
ksp = "2.0.0-1.0.21"
koin = "3.5.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
# Room Database
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Core Koin for Kotlin projects
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
# Koin for Jetpack Compose integration
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }