Skip to content

Commit aa0c212

Browse files
author
Z User
committed
fix: comprehensive bug audit - 25+ bugs fixed
CRITICAL fixes: - NoteDao: Added @transaction to getTrashedNotesWithTags & getFavoriteNotesWithTags (Room compile error) - NoteViewModel: Added _showFavoritesOnly to combine() - favorites feature was broken - ThemeManager: Fixed light mode - resource name case mismatch (lowercase vs PascalCase) - MarkdownHelper: Fixed TaskListPlugin.create() - wrong API signature (compilation error) HIGH fixes: - NoteRepository: Wrapped setTagsForNote in withTransaction (atomic tag operations) - NoteRepository: Fixed getOrCreateDailyNote race condition (withTransaction + re-query) - NoteRepository: Fixed getOrCreateTag race condition (handle -1L from IGNORE) - NoteRepository: Fixed sanitizeFtsQuery (escape double-quote characters) - EditorActivity: Fixed performSave() race with finish() - moved inside coroutine - EditorActivity: Fixed wrong note ID for reminder on new notes - AndroidManifest: Added SCHEDULE_EXACT_ALARM permission (Android 12+ crash) - AndroidManifest: Set allowBackup=false (incompatible with EncryptedSharedPreferences) - ExportImportHelper: Rewrote export to use MediaStore API (scoped storage fix) MEDIUM fixes: - ThemeManager.apply() moved before super.onCreate() in all 8 Activities - MainActivity: Fixed trash observer race (guard + cancel previous Job) - MainActivity: Fixed redundant if-else in showOptions() - ReminderReceiver: Replaced Class.forName() with direct class reference - NoteWidgetProvider: Replaced Class.forName() with direct class reference - GraphView: Replaced MainScope() with SupervisorJob scope + onDetachedFromWindow cancel - GraphView: Moved borderPaint out of onDraw() (GC pressure) - GraphView: Handle zero dimensions before layout - ProGuard: Removed overly broad -keepclassmembers rule - CryptoHelper: decrypt() now returns null on failure instead of returning ciphertext - EditorActivity: Handle null from CryptoHelper.decrypt() gracefully LOW fixes: - Extensions: Replaced SimpleDateFormat with java.time (thread-safe since minSdk 26) - ExportImportHelper: Added tag deduplication on import
1 parent de8fd79 commit aa0c212

26 files changed

+247
-128
lines changed

app/proguard-rules.pro

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,3 @@
2121
# Kotlin coroutines
2222
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
2323
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
24-
25-
# DataStore
26-
-keepclassmembers class * {
27-
<fields>;
28-
}

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
77
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
88
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
9+
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
10+
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
911

1012
<application
1113
android:name=".KitabuApplication"
12-
android:allowBackup="true"
13-
android:fullBackupContent="true"
14+
android:allowBackup="false"
1415
android:icon="@mipmap/ic_launcher"
1516
android:label="@string/app_name"
1617
android:roundIcon="@mipmap/ic_launcher_round"

app/src/main/kotlin/com/kitabu/app/data/NoteDao.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ interface NoteDao {
109109

110110
// --- Trash ---
111111

112+
@Transaction
112113
@Query("SELECT * FROM notes WHERE isTrashed = 1 ORDER BY trashedAt DESC")
113114
fun getTrashedNotesWithTags(): Flow<List<NoteWithTags>>
114115

@@ -120,6 +121,7 @@ interface NoteDao {
120121

121122
// --- Favorites ---
122123

124+
@Transaction
123125
@Query("SELECT * FROM notes WHERE isFavorite = 1 AND isArchived = 0 AND isTrashed = 0 ORDER BY updatedAt DESC")
124126
fun getFavoriteNotesWithTags(): Flow<List<NoteWithTags>>
125127

app/src/main/kotlin/com/kitabu/app/data/NoteRepository.kt

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.kitabu.app.data
22

3+
import androidx.room.withTransaction
34
import kotlinx.coroutines.flow.Flow
45
import java.time.LocalDate
56

67
class NoteRepository(
8+
private val database: KitabuDatabase,
79
private val noteDao: NoteDao,
810
private val tagDao: TagDao,
911
private val versionDao: NoteVersionDao
@@ -56,31 +58,44 @@ class NoteRepository(
5658

5759
suspend fun getOrCreateDailyNote(): Note {
5860
val today = LocalDate.now().toString()
59-
return noteDao.getDailyNote(today) ?: run {
60-
val newNote = Note(
61-
title = "📅 ${LocalDate.now()}",
62-
content = "## Daily Note — $today\n\n",
63-
isDaily = true,
64-
dailyDate = today,
65-
color = NoteColor.DEFAULT
66-
)
67-
val id = noteDao.insertNote(newNote)
68-
newNote.copy(id = id.toInt())
61+
return database.withTransaction {
62+
noteDao.getDailyNote(today) ?: run {
63+
val newNote = Note(
64+
title = "📅 ${LocalDate.now()}",
65+
content = "## Daily Note — $today\n\n",
66+
isDaily = true,
67+
dailyDate = today,
68+
color = NoteColor.DEFAULT
69+
)
70+
val id = noteDao.insertNote(newNote)
71+
// Re-query to handle race condition
72+
noteDao.getDailyNote(today) ?: newNote.copy(id = id.toInt())
73+
}
6974
}
7075
}
7176

7277
// --- Tags ---
7378

7479
suspend fun setTagsForNote(noteId: Int, tagIds: List<Int>) {
75-
noteDao.clearNoteTags(noteId)
76-
tagIds.forEach { noteDao.insertNoteTag(NoteTag(noteId, it)) }
80+
database.withTransaction {
81+
noteDao.clearNoteTags(noteId)
82+
tagIds.forEach { noteDao.insertNoteTag(NoteTag(noteId, it)) }
83+
}
7784
}
7885

7986
suspend fun getOrCreateTag(name: String): Tag {
80-
return tagDao.findTagByName(name) ?: run {
81-
val tag = Tag(name = name.lowercase().trim())
82-
val id = tagDao.insertTag(tag)
83-
tag.copy(id = id.toInt())
87+
val normalizedName = name.lowercase().trim()
88+
return database.withTransaction {
89+
tagDao.findTagByName(normalizedName) ?: run {
90+
val tag = Tag(name = normalizedName)
91+
val id = tagDao.insertTag(tag)
92+
if (id == -1L) {
93+
// Insert was ignored (race condition) — re-fetch the existing tag
94+
tagDao.findTagByName(normalizedName)!!
95+
} else {
96+
tag.copy(id = id.toInt())
97+
}
98+
}
8499
}
85100
}
86101

@@ -116,8 +131,9 @@ class NoteRepository(
116131

117132
private fun sanitizeFtsQuery(query: String): String {
118133
// FTS4 treats special chars as operators; wrap terms in double quotes for safety
134+
// Escape any double-quote characters by doubling them
119135
return query.trim().split("\\s+".toRegex())
120136
.filter { it.isNotBlank() }
121-
.joinToString(" ") { "\"$it\"" }
137+
.joinToString(" ") { """"${it.replace("\"", "\"\"")}"""" }
122138
}
123139
}

app/src/main/kotlin/com/kitabu/app/data/TagRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class TagRepository(private val dao: TagDao) {
77
suspend fun insert(tag: Tag): Long = dao.insertTag(tag)
88
suspend fun update(tag: Tag) = dao.updateTag(tag)
99
suspend fun delete(tag: Tag) = dao.deleteTag(tag)
10+
suspend fun deleteTagById(id: Int) = dao.deleteTagById(id)
1011
suspend fun find(name: String): Tag? = dao.findTagByName(name)
1112
suspend fun getNoteCount(tagId: Int): Int = dao.getNoteCountForTag(tagId)
1213
}

app/src/main/kotlin/com/kitabu/app/ui/ai/AiAssistantActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ class AiAssistantActivity : AppCompatActivity() {
3838
private val history = mutableListOf<Pair<String, String>>() // role to text
3939

4040
override fun onCreate(savedInstanceState: Bundle?) {
41-
super.onCreate(savedInstanceState)
4241
ThemeManager.apply(this)
42+
super.onCreate(savedInstanceState)
4343
binding = ActivityAiAssistantBinding.inflate(layoutInflater)
4444
setContentView(binding.root)
4545
setSupportActionBar(binding.toolbar)

app/src/main/kotlin/com/kitabu/app/ui/editor/EditorActivity.kt

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ import com.kitabu.app.util.*
3636
import kotlinx.coroutines.Job
3737
import kotlinx.coroutines.delay
3838
import kotlinx.coroutines.launch
39-
import java.io.File
40-
import java.io.FileOutputStream
4139
import java.util.Calendar
4240
import java.util.Locale
4341

@@ -74,8 +72,8 @@ class EditorActivity : AppCompatActivity() {
7472
}
7573

7674
override fun onCreate(savedInstanceState: Bundle?) {
77-
super.onCreate(savedInstanceState)
7875
ThemeManager.apply(this)
76+
super.onCreate(savedInstanceState)
7977
binding = ActivityEditorBinding.inflate(layoutInflater)
8078
setContentView(binding.root)
8179
setSupportActionBar(binding.toolbar)
@@ -129,7 +127,7 @@ class EditorActivity : AppCompatActivity() {
129127

130128
// Decrypt content if locked
131129
val content = if (nwt.note.isLocked) {
132-
CryptoHelper.decrypt(nwt.note.content)
130+
CryptoHelper.decrypt(nwt.note.content) ?: nwt.note.content
133131
} else {
134132
nwt.note.content
135133
}
@@ -657,23 +655,23 @@ class EditorActivity : AppCompatActivity() {
657655

658656
// ── Export single note ────────────────────────────────────────────
659657

660-
private fun exportNote() {
661-
lifecycleScope.launch {
658+
private val exportNoteLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/markdown")) { uri: Uri? ->
659+
if (uri != null) {
662660
try {
663-
val title = binding.etTitle.text.toString().ifBlank { "Untitled" }
664661
val content = binding.etContent.text.toString()
665-
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
666-
android.os.Environment.DIRECTORY_DOWNLOADS
667-
)
668-
val file = File(downloadsDir, "$title.md")
669-
FileOutputStream(file).use { it.write(content.toByteArray()) }
670-
Snackbar.make(binding.root, "Exported to ${file.absolutePath}", Snackbar.LENGTH_LONG).show()
662+
contentResolver.openOutputStream(uri)?.use { it.write(content.toByteArray()) }
663+
Snackbar.make(binding.root, "Note exported", Snackbar.LENGTH_LONG).show()
671664
} catch (e: Exception) {
672665
Snackbar.make(binding.root, "Export failed: ${e.message}", Snackbar.LENGTH_SHORT).show()
673666
}
674667
}
675668
}
676669

670+
private fun exportNote() {
671+
val title = binding.etTitle.text.toString().ifBlank { "Untitled" }
672+
exportNoteLauncher.launch("$title.md")
673+
}
674+
677675
// ── Save ──────────────────────────────────────────────────────────
678676

679677
private fun performSave(silent: Boolean = false) {
@@ -711,11 +709,13 @@ class EditorActivity : AppCompatActivity() {
711709
currentNoteId = id.toInt()
712710
existingNote = note.copy(id = currentNoteId)
713711
vm.setTagsForNote(currentNoteId, selectedTagIds)
714-
reminderTime?.let { ReminderHelper.scheduleReminder(this@EditorActivity, note.copy(reminderTime = it)) }
712+
reminderTime?.let {
713+
ReminderHelper.scheduleReminder(this@EditorActivity, note.copy(id = currentNoteId, reminderTime = it))
714+
}
715715
lastSavedHash = currentHash
716716
}
717+
if (!silent) finish()
717718
}
718-
if (!silent) finish()
719719
}
720720

721721
// ── Options menu ──────────────────────────────────────────────────

app/src/main/kotlin/com/kitabu/app/ui/graph/GraphActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class GraphActivity : AppCompatActivity() {
1717
private val vm: NoteViewModel by viewModels()
1818

1919
override fun onCreate(savedInstanceState: Bundle?) {
20-
super.onCreate(savedInstanceState)
2120
ThemeManager.apply(this)
21+
super.onCreate(savedInstanceState)
2222
binding = ActivityGraphBinding.inflate(layoutInflater)
2323
setContentView(binding.root)
2424
setSupportActionBar(binding.toolbar)

app/src/main/kotlin/com/kitabu/app/ui/graph/GraphView.kt

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import android.view.*
77
import kotlinx.coroutines.CoroutineScope
88
import kotlinx.coroutines.Dispatchers
99
import kotlinx.coroutines.Job
10-
import kotlinx.coroutines.MainScope
10+
import kotlinx.coroutines.SupervisorJob
11+
import kotlinx.coroutines.cancel
1112
import kotlinx.coroutines.launch
1213
import kotlinx.coroutines.withContext
1314
import kotlin.math.*
@@ -60,7 +61,13 @@ class GraphView @JvmOverloads constructor(
6061
}
6162
})
6263

63-
private val scope = MainScope()
64+
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
65+
style = Paint.Style.STROKE
66+
strokeWidth = 2f
67+
color = 0x66FFFFFF.toInt()
68+
}
69+
70+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
6471
private var layoutJob: Job? = null
6572

6673
fun setData(newNodes: List<Node>, newEdges: List<Edge>) {
@@ -69,7 +76,20 @@ class GraphView @JvmOverloads constructor(
6976
layoutJob?.cancel()
7077
layoutJob = scope.launch {
7178
val w = width.toFloat(); val h = height.toFloat()
72-
val layoutNodes = withContext(Dispatchers.Default) {
79+
if (w == 0f || h == 0f) {
80+
// Wait for layout before computing positions
81+
kotlinx.coroutines.delay(50)
82+
val w2 = width.toFloat(); val h2 = height.toFloat()
83+
if (w2 == 0f || h2 == 0f) return@launch
84+
startLayout(w2, h2)
85+
} else {
86+
startLayout(w, h)
87+
}
88+
}
89+
}
90+
91+
private suspend fun startLayout(w: Float, h: Float) {
92+
val layoutNodes = withContext(Dispatchers.Default) {
7393
val localNodes = nodes.map { it.copy() }.toMutableList()
7494
layoutNodesInternal(localNodes, w, h)
7595
localNodes
@@ -83,6 +103,11 @@ class GraphView @JvmOverloads constructor(
83103
}
84104
}
85105

106+
override fun onDetachedFromWindow() {
107+
super.onDetachedFromWindow()
108+
scope.cancel()
109+
}
110+
86111
private fun layoutNodesInternal(localNodes: MutableList<Node>, w: Float, h: Float) {
87112
if (localNodes.isEmpty()) return
88113
val rnd = java.util.Random(42)
@@ -143,11 +168,6 @@ class GraphView @JvmOverloads constructor(
143168
nodePaint.color = n.color
144169
canvas.drawCircle(n.x, n.y, NODE_R, nodePaint)
145170
// Border
146-
val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
147-
style = Paint.Style.STROKE
148-
strokeWidth = 2f
149-
color = 0x66FFFFFF.toInt()
150-
}
151171
canvas.drawCircle(n.x, n.y, NODE_R, borderPaint)
152172
// Label
153173
val label = n.label.take(16)

app/src/main/kotlin/com/kitabu/app/ui/history/VersionHistoryActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ class VersionHistoryActivity : AppCompatActivity() {
2323
private val vm: NoteViewModel by viewModels()
2424

2525
override fun onCreate(savedInstanceState: Bundle?) {
26-
super.onCreate(savedInstanceState)
2726
ThemeManager.apply(this)
27+
super.onCreate(savedInstanceState)
2828
binding = ActivityVersionHistoryBinding.inflate(layoutInflater)
2929
setContentView(binding.root)
3030
setSupportActionBar(binding.toolbar)

0 commit comments

Comments
 (0)