Background
I'm building Tagmind โ a tag-based notes app for Android. One of the core features is a 30-day recovery window: when you delete a note, it goes to Trash and stays there for 30 days before being permanently purged. Standard stuff.
The data model is what you'd expect:
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String,
val isDeleted: Boolean = false,
val deletedAt: Long? = null, // epoch millis
// ...
)Two Room queries โ one for active notes, one for deleted:
@Query("SELECT * FROM notes WHERE isDeleted = 0")
fun getActiveNotes(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes WHERE isDeleted = 1")
fun getDeletedNotes(): Flow<List<NoteEntity>>Simple and clean. I thought I was done.
Then I went to test it manually.
Bug 1: "Clear All Notes" Skipped the Trash Entirely
I tapped Settings โ Clear All Notes, then opened Trash. Empty.
I expected everything to be in Trash. Instead the notes were just gone.
The culprit was in SettingsViewModel:
// What I had
fun clearAllNotes() {
viewModelScope.launch { noteRepository.permanentlyDeleteAllNotes() }
}I had wired clearAllNotes() to the permanent delete path instead of soft-delete. So the confirmation dialog that says "items will be recoverable for 30 days" was a lie โ it was immediately nuking everything.
The fix was one word:
// Fixed
fun clearAllNotes() {
viewModelScope.launch { noteRepository.softDeleteAllNotes() }
}But I also needed to add softDeleteAllNotes() to the DAO, since it didn't exist yet:
@Query("UPDATE notes SET isDeleted = 1, deletedAt = :deletedAt WHERE isDeleted = 0")
suspend fun softDeleteAllNotes(deletedAt: Long)Bug 2: Restoring a Note Created a Folder Instead
This one was weirder. I soft-deleted a note called "t1" that was inside a folder. Opened Trash. The item in the list showed the folder name, not "t1". I tapped Restore โ and instead of the note coming back, a new empty folder appeared in my home screen.
After some digging, the root cause was in TrashViewModel. It was building the trash item list like this:
// Broken version
val items = combine(noteRepository.getDeletedNotes(), categoryRepository.getDeletedCategories()) {
notes, folders ->
val noteItems = notes.map { TrashItem(note = it, folder = null) }
val folderItems = folders.map { TrashItem(note = null, folder = it) }
(noteItems + folderItems).sortedByDescending { /* ??? */ }
}The sortedByDescending was comparing updatedAt โ but updatedAt was the same for both the note and its folder (they were created at the same time), so the sort was putting the folder item first, and the UI was showing the wrong item.
But there was a deeper issue: deletedAt wasn't even in my domain models. I was tracking it in the Room entity but not mapping it through to Note or Category. So the sort key was wrong, and the TrashItem had no way to know when it was actually deleted.
Fix 1: Add deletedAt: Instant? to the domain models.
data class Note(
// ...
val deletedAt: Instant? = null,
)
data class Category(
// ...
val deletedAt: Instant? = null,
)Fix 2: Map it through in the mapper.
fun NoteWithTags.toDomain(): Note = Note(
// ...
deletedAt = note.deletedAt?.let { Instant.ofEpochMilli(it) }
)Fix 3: Use deletedAt for sorting in TrashViewModel.
val noteItems = notes.map { TrashItem(
note = it,
folder = null,
deletedAtMillis = it.deletedAt?.toEpochMilli() ?: it.updatedAt.toEpochMilli()
)}Bug 3: Notes Inside Deleted Folders Couldn't Be Restored
After fixing Bug 2, I tried restoring a note that had been inside a soft-deleted folder.
The note came back โ but it was invisible. It didn't appear on the home screen even though the database showed it was active.
The reason: my getAllCategories() query filters by isDeleted = 0. If the folder the note belongs to is still soft-deleted, HomeViewModel has no way to display the note โ it has a categoryId pointing to a folder that doesn't appear in any active category list.
The fix was to check at restore time: if the note's folder is also in Trash, restore it too.
fun restoreNote(note: Note) = viewModelScope.launch {
// If note is in a deleted folder, restore the folder first
note.categoryId?.let { catId ->
val isDeletedFolder = items.value.any { it.folder?.id == catId }
if (isDeletedFolder) categoryRepository.restore(catId)
}
noteRepository.restoreNote(note.id)
}This way, restoring a note always leaves it in a visible state โ even if its parent folder was also deleted.
What I Learned
Soft-delete is not just "add a boolean."
If you soft-delete items that have relationships, every restore path needs to be aware of those relationships. A note inside a folder is useless if you restore the note but leave the folder deleted.
Domain models need to carry the full picture.
I was tracking deletedAt in Room entities but not in domain models. That made it invisible at the ViewModel layer โ and caused the wrong sort order, which surfaced as a completely different-looking bug.
The test that caught this:
Once I fixed the bugs and wrote TrashViewModelTest, this case became a 3-line test:
@Test
fun `restoring note inside deleted folder also restores folder`() = runTest {
val cat = categoryRepo.getOrCreate("Projects")
noteRepo.insertNote(Note(id = 4L, title = "Inside Folder", categoryId = cat.id))
noteRepo.softDeleteNote(4L)
categoryRepo.softDelete(cat)
val vm = buildVm(); subscribeToItems(vm)
advanceUntilIdle()
val noteItem = vm.items.value.first { it.note != null }
vm.restoreNote(noteItem.note!!)
advanceUntilIdle()
assertEquals(1, categoryRepo.activeCategories().size) // folder also restored
assertEquals(1, noteRepo.activeNotes().size)
}Three bugs, one testing session. All of them were invisible until I actually went through the flow manually โ then impossible to miss.
The pattern that now protects against this is simple: for every delete path (single note, single folder, clear all), there's a corresponding restore test that verifies state from the perspective of what the user will actually see.