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.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt)
alias(libs.plugins.ksp)
}
import java.util.Properties
@@ -162,6 +163,15 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
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
// ═══════════════════════════════════════════════════════════════════════

View File

@@ -36,8 +36,6 @@ import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.utils.NotificationHelper
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() {
@@ -30,6 +34,12 @@ class SimpleNotesApplication : Application() {
override fun 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)
// 🔧 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
import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
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.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 {
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 {
if (!exists()) mkdirs()
private val noteDao = database.noteDao()
private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) {
noteDao.saveNote(note)
}
fun saveNote(note: Note) {
val file = File(notesDir, "${note.id}.json")
file.writeText(note.toJson())
suspend fun loadNote(id: String): NoteEntity? {
return noteDao.getNote(id)
}
fun loadNote(id: String): Note? {
val file = File(notesDir, "$id.json")
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
}
suspend fun loadAllNotes(): List<NoteEntity> {
return noteDao.getAllNotes()
}
/**
* Lädt alle Notizen aus dem lokalen Speicher.
*
* 🔀 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()
}
suspend fun deleteNote(id: String): Boolean {
val deletedRows = noteDao.deleteNoteById(id)
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
if (deleted) {
if (deletedRows > 0) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
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 {
val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
// Batch tracking and deleting
notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
}
noteDao.deleteAllNotes()
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true
@@ -82,101 +64,28 @@ class NotesStorage(private val context: Context) {
// === 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) {
deletionTrackerMutex.withLock {
val tracker = loadDeletionTracker()
tracker.addDeletion(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)
// Room handles internal transactions and thread-safety natively.
// The Mutex is no longer required.
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
Logger.d(TAG, "📝 Tracked deletion: $noteId")
}
fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker()
return tracker.isDeleted(noteId)
suspend fun isNoteDeleted(noteId: String): Boolean {
return deletedNoteDao.isNoteDeleted(noteId)
}
fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker())
suspend fun clearDeletionTracker() {
deletedNoteDao.clearTracker()
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++
}
}
suspend fun resetAllSyncStatusToPending(): Int {
val updatedCount = noteDao.updateSyncStatus(
oldStatus = SyncStatus.SYNCED,
newStatus = SyncStatus.PENDING
)
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
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.NotificationHelper
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Main Activity with Jetpack Compose UI
@@ -65,7 +66,7 @@ class ComposeMainActivity : ComponentActivity() {
private const val REQUEST_SETTINGS = 1002
}
private val viewModel: MainViewModel by viewModels()
private val viewModel: MainViewModel by viewModel()
private val prefs by lazy {
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.SyncStatusLegendDialog
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
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)
@Composable
fun MainScreen(
viewModel: MainViewModel,
viewModel: MainViewModel = koinViewModel(),
onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit

View File

@@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection
@@ -34,7 +36,10 @@ import kotlinx.coroutines.withContext
*
* 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 {
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 val storage = NotesStorage(application)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════
// Notes State
// ═══════════════════════════════════════════════════════════════════════
@@ -207,7 +209,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes()
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) {
// 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"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"
room = "2.6.1"
ksp = "2.0.0-1.0.21"
koin = "3.5.3"
[libraries]
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-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" }
# 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]
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" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }