Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8234092
feat: contract-driven dynamic database download from API
catreedle Feb 10, 2026
d49a99c
feat: persist download states across app relaunches
catreedle Feb 11, 2026
ed6e803
fix: fix installed keyboards not showing in Settings
catreedle Feb 11, 2026
5e2c593
feat: trigger download and "Downloading" state from Select Translatio…
catreedle Feb 11, 2026
88f8148
redirect to Download Screen on confirming translation source change
catreedle Feb 11, 2026
cf24823
feat: set Update state for download button using data version endpoint
catreedle Feb 12, 2026
d645f83
fix download Toast display message
catreedle Feb 13, 2026
14f2ebb
Add new YAML based contract files
andrewtavis Feb 15, 2026
f23e677
Finalize form of YAML contracts
andrewtavis Feb 15, 2026
3af7f7a
Fix included double quote in en.yaml
andrewtavis Feb 15, 2026
8dad758
Update version of data contracts with necessary fields
andrewtavis Feb 15, 2026
17626a2
Minor fix in comment in contracts
andrewtavis Feb 15, 2026
430c058
feat: change to using YAML for data contract
catreedle Feb 16, 2026
8d0d370
remove json contracts
catreedle Feb 16, 2026
19e6fc7
fix minor typo
catreedle Feb 16, 2026
f5b6ff0
feat: read from new db and check for table and column existence
catreedle Feb 17, 2026
1bef6c9
Merge branch 'main' into read-new-db
catreedle Mar 2, 2026
7944c77
fix minor import
catreedle Mar 2, 2026
cac5b54
fix build error from merge conflict
catreedle Mar 2, 2026
704507b
fix merge conflict duplicate functions
catreedle Mar 2, 2026
708b80b
remove Download All state and fix crashes
catreedle Mar 3, 2026
c9b8e7b
fix "Check for new data" as spinner
catreedle Mar 4, 2026
6bd2f0e
fix avoid action once Check for new data is done
catreedle Mar 5, 2026
5265b9f
feat: MVP autocompletions fallback using nouns table
catreedle Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed app/src/main/assets/data/DELanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/ENLanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/ESLanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/FRLanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/ITLanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/PTLanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/RULanguageData.sqlite
Binary file not shown.
Binary file removed app/src/main/assets/data/SVLanguageData.sqlite
Binary file not shown.
28 changes: 21 additions & 7 deletions app/src/main/java/be/scri/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
Expand All @@ -38,7 +39,9 @@ import be.scri.ui.screens.SelectTranslationSourceLanguageScreen
import be.scri.ui.screens.ThirdPartyScreen
import be.scri.ui.screens.WikimediaScreen
import be.scri.ui.screens.about.AboutScreen
import be.scri.ui.screens.download.CheckUpdateActions
import be.scri.ui.screens.download.DataDownloadViewModel
import be.scri.ui.screens.download.DownloadActions
import be.scri.ui.screens.download.DownloadDataScreen
import be.scri.ui.screens.settings.SettingsScreen
import be.scri.ui.theme.ScribeTheme
Expand Down Expand Up @@ -82,8 +85,21 @@ fun ScribeApp(
val downloadStates = downloadViewModel.downloadStates
val onDownloadAction = downloadViewModel::handleDownloadAction
val onDownloadAll = downloadViewModel::handleDownloadAllLanguages
val inititalizeStates = downloadViewModel::initializeStates
val checkAllForUpdates = downloadViewModel::checkAllForUpdates
val initializeStates = downloadViewModel::initializeStates
val downloadActions =
DownloadActions(
downloadStates = downloadStates,
onDownloadAction = onDownloadAction,
onDownloadAll = onDownloadAll,
initializeStates = initializeStates,
)
val checkUpdateState by downloadViewModel.checkUpdateState.collectAsState()
val checkUpdateActions =
CheckUpdateActions(
checkUpdateState = checkUpdateState,
checkForNewData = downloadViewModel::checkForNewData,
cancelCheckForNewData = downloadViewModel::cancelCheckForNewData,
)

ScribeTheme(
useDarkTheme = isDarkTheme,
Expand Down Expand Up @@ -213,11 +229,9 @@ fun ScribeApp(
"translation_language_detail/$language",
)
},
downloadStates = downloadStates,
onDownloadAction = onDownloadAction,
onDownloadAll = onDownloadAll,
initializeStates = inititalizeStates,
checkAllForUpdates = checkAllForUpdates,
isDarkTheme = isDarkTheme,
downloadActions = downloadActions,
checkUpdateActions = checkUpdateActions,
modifier = Modifier.padding(innerPadding),
)
}
Expand Down
31 changes: 15 additions & 16 deletions app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import be.scri.data.model.DataResponse
class DynamicDbHelper(
context: Context,
language: String,
) : SQLiteOpenHelper(context, "$language.db", null, 1) {
) : SQLiteOpenHelper(context, "${language.uppercase()}LanguageData.sqlite", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
// Tables are created dynamically via syncDatabase from API contract.
}
Expand All @@ -36,35 +36,34 @@ class DynamicDbHelper(
*/
fun syncDatabase(response: DataResponse) {
val db = writableDatabase
try {
db.beginTransaction()

// Create Tables.
response.contract.fields.forEach { (tableName, columns) ->
val colDefinition = columns.keys.joinToString(", ") { "$it TEXT" }
db.execSQL("CREATE TABLE IF NOT EXISTS $tableName (id INTEGER PRIMARY KEY AUTOINCREMENT, $colDefinition)")
db.execSQL("DELETE FROM $tableName") // clear old data
}
response.contract.fields.forEach { (tableName, columns) ->
val colDefinition = columns.keys.joinToString(", ") { "$it TEXT" }
db.execSQL("DROP TABLE IF EXISTS $tableName")
db.execSQL(
"CREATE TABLE $tableName " +
"(id INTEGER PRIMARY KEY AUTOINCREMENT, $colDefinition)",
)
}

// Insert Data with Transaction.
db.beginTransaction()
try {
response.data.forEach { (tableName, rows) ->

val cv = ContentValues()
rows.forEach { row ->
val cv = ContentValues()
cv.clear()
row.forEach { (key, value) ->
cv.put(key, value?.toString() ?: "")
}
val result = db.insert(tableName, null, cv)
if (result == -1L) {
Log.e("SCRIBE_DB", "Failed to insert row into $tableName")
}
db.insert(tableName, null, cv)
}
}
db.setTransactionSuccessful()
} catch (e: SQLiteException) {
Log.e("SCRIBE_DB", "Error during insert: ${e.message}")
} finally {
db.endTransaction()
db.close()
}
}
}
14 changes: 13 additions & 1 deletion app/src/main/java/be/scri/helpers/DatabaseFileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@ class DatabaseFileManager(
*/
fun getLanguageDatabase(language: String): SQLiteDatabase? {
val dbName = "${language}LanguageData.sqlite"
return getDatabase(dbName, "data/$dbName")
val dbFile = context.getDatabasePath(dbName)

if (!dbFile.exists()) {
Log.w(TAG, "Database $dbName not found. User needs to download data first")
return null
}

return try {
SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY)
} catch (e: SQLiteException) {
Log.e(TAG, "Failed to open database $dbName", e)
null
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class AutoSuggestionDataManager(
val suggestionMap = HashMap<String, List<String>>()
val columnsToSelect = listOf("word", "autosuggestion_0", "autosuggestion_1", "autosuggestion_2")

if (!db.tableExists("autosuggestions")) return suggestionMap
db.rawQuery("SELECT * FROM autosuggestions LIMIT 1", null).use { tempCursor ->
for (column in columnsToSelect) {
if (tempCursor.getColumnIndex(column) == -1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,62 @@ import be.scri.helpers.DatabaseFileManager
* This class manages the autocomplete system.
* It loads words from a language-specific SQLite database,
* and stores them in a Trie data structure for fast prefix-based lookup.
* If the `autocomplete_lexicon` table/Trie is not available, it falls back to caching all noun words.
*/
class AutocompletionDataManager(
private val fileManager: DatabaseFileManager,
) {
private val trie = Trie()
private var trieLoaded = false
private val nounWords = mutableListOf<String>()

/**
* Loads all words from the language-specific database into the trie.
* If the `autocomplete_lexicon` table/Trie is not present, it loads noun words from the specified columns instead.
*
* @param language The language code (e.g. "en", "id") for which to load words.
* @param numbersColumns Column names from the contract's `numbers` map.
*/
fun loadWords(language: String) {
val db = fileManager.getLanguageDatabase(language)
db?.rawQuery("SELECT word FROM autocomplete_lexicon", null).use { cursor ->
val wordIndex = cursor!!.getColumnIndex("word")
while (cursor.moveToNext()) {
val word = cursor.getString(wordIndex)?.lowercase()?.trim()
if (!word.isNullOrEmpty()) {
trie.insert(word)
fun loadWords(
language: String,
numbersColumns: List<String> = emptyList(),
) {
val db = fileManager.getLanguageDatabase(language) ?: return

db.use { database ->
if (database.tableExists("autocomplete_lexicon")) {
database.rawQuery("SELECT word FROM autocomplete_lexicon", null).use { cursor ->
val wordIndex = cursor.getColumnIndex("word")
while (cursor.moveToNext()) {
val word = cursor.getString(wordIndex)?.lowercase()?.trim()
if (!word.isNullOrEmpty()) {
trie.insert(word)
}
}
}
trieLoaded = true
} else if (database.tableExists("nouns") && numbersColumns.isNotEmpty()) {
val unionQuery =
numbersColumns.joinToString(" UNION ") { column ->
"SELECT DISTINCT $column AS word FROM nouns WHERE $column IS NOT NULL AND $column != ''"
} + " ORDER BY word ASC"

database.rawQuery(unionQuery, null).use { cursor ->
val wordIndex = cursor.getColumnIndex("word")
while (cursor.moveToNext()) {
val word = cursor.getString(wordIndex)?.lowercase()?.trim()
if (!word.isNullOrEmpty()) {
nounWords.add(word)
}
}
}
}
}
}

/**
* Returns autocomplete suggestions for a given prefix.
* Uses the Trie if loaded, otherwise filters the cached noun word list.
*
* @param prefix The starting text to search for (e.g. "ap").
* @param limit The maximum number of suggestions to return (default: 3).
Expand All @@ -42,5 +72,24 @@ class AutocompletionDataManager(
fun getAutocompletions(
prefix: String,
limit: Int = 3,
): List<String> = trie.searchPrefix(prefix, limit)
): List<String> =
if (trieLoaded) {
trie.searchPrefix(prefix, limit)
} else {
getAutocompletionsFromNouns(prefix, limit)
}

/**
* Filters the cached noun word list to find matches that start with the given prefix.
*/
private fun getAutocompletionsFromNouns(
prefix: String,
limit: Int,
): List<String> {
if (nounWords.isEmpty() || prefix.isBlank()) return emptyList()
val normalizedPrefix = prefix.lowercase().trim()
return nounWords
.filter { it.startsWith(normalizedPrefix) }
.take(limit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ class ConjugateDataManager(
): String {
if (form.isNullOrEmpty()) return ""
return fileManager.getLanguageDatabase(language)?.use { db ->
if (!db.tableExists("verbs")) {
return ""
}

val columnName = if (language == "SV") "verb" else "infinitive"
if (!db.columnExists("verbs", columnName)) {
return ""
}

getVerbCursor(db, word, language)?.use { cursor ->
getConjugatedValueFromCursor(cursor, form, language)
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class EmojiDataManager(
val db = fileManager.getLanguageDatabase(language) ?: return emojiMap

db.use {
if (!it.tableExists("emoji_keywords")) return emojiMap

it.rawQuery("SELECT MAX(LENGTH(word)) FROM emoji_keywords", null).use { cursor ->
if (cursor.moveToFirst()) {
maxKeywordLength = cursor.getInt(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class PrepositionDataManager(
return hashMapOf()
}
return fileManager.getLanguageDatabase(language)?.use { db ->
if (!db.tableExists("prepositions")) return@use hashMapOf()
db.rawQuery("SELECT preposition, grammaticalCase FROM prepositions", null).use { cursor ->
processCursor(cursor)
} // handle case where cursor is null
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package be.scri.helpers.data

import android.database.sqlite.SQLiteDatabase

fun SQLiteDatabase.tableExists(tableName: String): Boolean =
rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name='$tableName'",
null,
).use { it.moveToFirst() }

fun SQLiteDatabase.columnExists(
tableName: String,
columnName: String,
): Boolean =
rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex("name")
do {
if (cursor.getString(nameIndex) == columnName) {
return true
}
} while (cursor.moveToNext())
}
false
}
6 changes: 5 additions & 1 deletion app/src/main/java/be/scri/services/GeneralKeyboardIME.kt
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,11 @@ abstract class GeneralKeyboardIME(
?.toSet()
nounKeywords = dbManagers.genderManager.findGenderOfWord(languageAlias, dataContract)
suggestionWords = dbManagers.suggestionManager.getSuggestions(languageAlias)
autocompletionManager.loadWords(languageAlias)
val numbersColumns =
dataContract?.numbers?.let { map ->
(map.keys + map.values).distinct()
} ?: emptyList()
autocompletionManager.loadWords(languageAlias, numbersColumns)
caseAnnotation = dbManagers.prepositionManager.getCaseAnnotations(languageAlias)

val tempConjugateOutput = dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, dataContract, "describe")
Expand Down
Loading
Loading