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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
} }
fun deleteAllNotes(): Boolean { suspend 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
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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