Young Devs Bin
๐Ÿ”ง Dev Log

Tappable Note References, Save as Note, and Encrypted Search History in Tagmind

ยท5 min readยท#android#kotlin#compose#llm#security#room#tagmind

What Was Built

Three features came out of the same session:

  1. Tappable note references โ€” after an AI answer, tappable chips appear below showing which notes the LLM referenced. Tap one to go straight to that note.
  2. Save as note โ€” a "Save as note" button appears under the AI answer. Tapping it opens a folder picker and navigates to the note editor pre-filled with the question and answer.
  3. Encrypted search history โ€” past AI searches are saved locally and shown when you return to the screen. Old entries (30+ days) are automatically purged. Everything is encrypted with Android Keystore AES-GCM.

Tappable Note References

The core change was updating the LLM prompt to request a structured JSON response instead of plain text:

Here are the user's notes (each has a Note ID):

[Note ID: 1]
Title: Kotlin Flow cheat sheet
...

User question: What notes do I have about Kotlin?

Respond ONLY with valid JSON in this exact format:
{"answer": "your answer here", "referenced_ids": [1, 2, 3]}
Only include IDs of notes you actually referenced in your answer.

The response comes back as a LlmSearchResult:

data class LlmSearchResult(
    val answer: String,
    val referencedNoteIds: List<Long> = emptyList(),
)

Parsing has a fallback because LLMs don't always honor JSON instructions perfectly:

private fun parseSearchResult(raw: String): LlmSearchResult {
    if (raw.startsWith("Error:")) return LlmSearchResult(answer = raw)
    return try {
        val jsonStart = raw.indexOf('{')
        val jsonEnd   = raw.lastIndexOf('}')
        if (jsonStart != -1 && jsonEnd > jsonStart) {
            val parsed = gson.fromJson(raw.substring(jsonStart, jsonEnd + 1), LlmJsonResponse::class.java)
            if (parsed.answer.isNotBlank())
                LlmSearchResult(answer = parsed.answer, referencedNoteIds = parsed.referencedIds)
            else LlmSearchResult(answer = raw)
        } else LlmSearchResult(answer = raw)
    } catch (e: Exception) {
        LlmSearchResult(answer = raw)
    }
}

The ViewModel filters referenced notes from the already-fetched notes list โ€” no extra DB call needed:

_referencedNotes.value = notes.filter { it.id in result.referencedNoteIds }

Save as Note โ€” Folder Picker Flow

The original "Save as note" button directly created a note with no folder. The problem: Home screen's groupedNotes only shows notes with a non-null categoryId. Notes saved without a folder would exist in the DB but be invisible in the folder view.

The fix was to enforce folder selection. The new flow:

  1. Tap "Save as note"
  2. ModalBottomSheet opens with existing folders listed
  3. A "New folder" row expands inline to a text input when tapped
  4. Selecting or creating a folder calls viewModel.saveAnswerAsNote(folder.id) which inserts the note and returns the note ID
  5. Navigate immediately to NoteEditScreen with the new note pre-loaded
suspend fun saveAnswerAsNote(categoryId: Long?): Long {
    val answer = _llmAnswer.value ?: return -1L
    return noteRepository.insertNote(
        Note(
            title      = _query.value.take(80).ifBlank { "AI Answer" },
            content    = answer,
            categoryId = categoryId,
        )
    )
}

NoteEditViewModel handles the pre-fill: when initialNoteId != -1L, it loads the existing note by ID and populates title and content. The navigation just passes (noteId, categoryId) and the edit screen does the rest.


Folder Rename

The Home screen already had folder creation (+ button) and deletion (swipe). The missing piece was renaming.

Added rename() to the DAO:

@Query("UPDATE categories SET name = :name WHERE id = :id")
suspend fun rename(id: Long, name: String)

Added a โ‹ฎ button next to the + in each folder header row. Tapping it opens an AlertDialog with the current name pre-filled as editable text. Confirming calls viewModel.renameCategory(id, newName).


Encrypted Search History

The goal: persist LLM searches across app restarts, encrypted at rest, with a 30-day auto-purge.

Why Android Keystore, not SQLCipher

SQLCipher encrypts the whole DB file but adds a significant dependency and requires a passphrase management strategy. For field-level encryption of just two text fields (query and answer), Android Keystore AES-GCM is cleaner and has zero extra dependencies.

How it works

A LlmHistoryEncryptor singleton uses the Android Keystore to create (or retrieve) a per-device AES-256-GCM key:

KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
    init(
        KeyGenParameterSpec.Builder(keyAlias,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .build()
    )
    generateKey()
}

Each encrypt call generates a fresh random IV. The IV and ciphertext are stored together as a single string:

fun encrypt(plaintext: String): String {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
    val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
    val ct = Base64.encodeToString(cipher.doFinal(plaintext.toByteArray()), Base64.NO_WRAP)
    return "$iv:$ct"
}

The Room entity stores queryEncrypted and answerEncrypted. Provider, model, referenced note IDs, and timestamp are stored in plaintext โ€” they're not sensitive.

30-day purge

On SearchViewModel init, historyRepository.purgeOld() runs in the background:

@Query("DELETE FROM llm_history WHERE timestamp < :cutoff")
suspend fun deleteOlderThan(cutoff: Long)

30 days matches the note trash retention period โ€” consistent behavior across the app.

UI

History appears in the main search area when there's no active query. Each card shows the question, a 2-line answer preview, the provider and model, and a relative timestamp ("3h ago", "2d ago").

Up to 5 items show by default. A "See N more" button appears below if there are more. Tapping a card restores the query and answer so the user can re-read or re-ask.


What I Would Do Differently

The JSON parsing fallback is a smell. Asking an LLM to respond in a specific format and then writing fallback parsing for when it doesn't is a necessary evil โ€” but it means the feature degrades silently when the format isn't followed. A real production app would use structured outputs (available in OpenAI and Gemini APIs) that guarantee the response schema at the API level.

History is per-device only. The encryption key lives in the Android Keystore and can't be exported. This is intentional for privacy, but it means search history doesn't sync across devices and is lost on a factory reset. That's the right trade-off for a bring-your-own-key app โ€” but worth being explicit about in the UI.