Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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.
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}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 @@ -19,13 +19,18 @@ class AutocompletionDataManager(
* @param language The language code (e.g. "en", "id") for which to load words.
*/
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)
val db = fileManager.getLanguageDatabase(language) ?: return

db.use { database ->
if (!database.tableExists("autocomplete_lexicon")) return

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)
}
}
}
}
Expand Down
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
}
40 changes: 20 additions & 20 deletions app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package be.scri.ui.screens.download

import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
Expand Down Expand Up @@ -30,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -99,11 +101,10 @@ fun DownloadDataScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

// Prepare the list of languages to display, including the "All Languages" option.
// Prepare the list of languages to display.
val languages: ImmutableList<LanguageItem> =
remember(installedKeyboardLanguages) {
buildList {
add(LanguageItem("all", context.getString(R.string.i18n_app_download_menu_ui_download_data_all_languages), false))
installedKeyboardLanguages.forEach { languageCode ->
val displayName =
when (languageCode.lowercase()) {
Expand All @@ -122,16 +123,7 @@ fun DownloadDataScreen(
}.toImmutableList()
}

// Determine the state of the "All Languages" item based on individual language states.
val allLanguagesState =
when {
downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Completed } -> DownloadState.Completed
downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Downloading } -> DownloadState.Downloading
downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Update } -> DownloadState.Update
else -> DownloadState.Ready
}

LaunchedEffect(languages) {
LaunchedEffect(Unit) {
val keys = languages.map { it.key }
currentInitializeStates(keys)
}
Expand Down Expand Up @@ -206,7 +198,6 @@ fun DownloadDataScreen(
} else {
LanguagesListSection(
languages = languages,
allLanguagesState = allLanguagesState,
downloadStates = downloadStates,
onLanguageSelect = { selectedLanguage.value = it },
onDownloadAll = onDownloadAll,
Expand Down Expand Up @@ -286,16 +277,14 @@ private fun EmptyStateSection(context: Context) {
* Composable function to display the list of languages available for download, along with their respective download states and actions.
*
* @param languages List of [LanguageItem] representing the available languages.
* @param allLanguagesState The overall download state for all languages.
* @param downloadStates Map of individual language keys to their respective [DownloadState].
* @param onLanguageSelect Callback invoked when a specific language is selected for download.
* @param onDownloadAll Callback invoked when the "All Languages" option is selected for download.
* @param onDownloadAll Callback invoked when the "Update all" is clicked for download.
* @param onDownloadAction Callback invoked when a specific language's download action is triggered, with parameters for language key and whether it's an "all" action.
*/
@Composable
private fun LanguagesListSection(
languages: ImmutableList<LanguageItem>,
allLanguagesState: DownloadState,
downloadStates: Map<String, DownloadState>,
onLanguageSelect: (LanguageItem) -> Unit,
onDownloadAll: () -> Unit,
Expand All @@ -310,16 +299,27 @@ private fun LanguagesListSection(
color = MaterialTheme.colorScheme.surface,
) {
Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) {
Text(
text = "Update all",
color = colorResource(R.color.dark_scribe_blue),
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
modifier =
Modifier
.padding(horizontal = 12.dp, vertical = 10.dp)
.align(Alignment.End)
.clickable {
onDownloadAll()
},
)
languages.forEachIndexed { index, lang ->
val currentStatus = if (lang.key == "all") allLanguagesState else (downloadStates[lang.key] ?: DownloadState.Ready)
val currentStatus = downloadStates[lang.key] ?: DownloadState.Ready

LanguageItemComp(
title = lang.displayName,
onClick = { },
onButtonClick = {
if (lang.key == "all") {
onDownloadAll()
} else if (currentStatus == DownloadState.Ready) {
if (currentStatus == DownloadState.Ready) {
onLanguageSelect(lang)
} else {
onDownloadAction(lang.key, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import be.scri.data.remote.RetrofitClient
import be.scri.helpers.LanguageMappingConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import retrofit2.HttpException
import java.io.IOException
import java.time.LocalDate
Expand All @@ -26,6 +28,7 @@ class DataDownloadViewModel(
application: Application,
) : AndroidViewModel(application) {
val downloadStates = mutableStateMapOf<String, DownloadState>()
private val downloadSemaphore = kotlinx.coroutines.sync.Semaphore(2)
private val downloadJobs = mutableMapOf<String, Job>()
private val prefs = getApplication<Application>().getSharedPreferences("scribe_prefs", Context.MODE_PRIVATE)

Expand Down Expand Up @@ -115,9 +118,13 @@ class DataDownloadViewModel(
// Store the job so we can cancel it later if needed.
downloadJobs[key] =
viewModelScope.launch(Dispatchers.IO) {
downloadSemaphore.acquire()
try {
// Fetch API.
val response = RetrofitClient.apiService.getData(langCode)
val response =
withTimeout(30_000) {
RetrofitClient.apiService.getData(langCode)
}
val serverLastUpdate = response.contract.updatedAt

// Always download when forcing, or when update is available.
Expand Down Expand Up @@ -145,8 +152,12 @@ class DataDownloadViewModel(
updateErrorState(key, "Database Error: ${e.message}")
} catch (e: HttpException) {
updateErrorState(key, "Server Error: ${e.code()}")
} catch (e: TimeoutCancellationException) {
updateErrorState(key, "Download timed out")
throw e
} finally {
// Clean up the job reference when done.
downloadSemaphore.release()
downloadJobs.remove(key)
}
}
Expand All @@ -158,7 +169,7 @@ class DataDownloadViewModel(
fun handleDownloadAllLanguages() {
val toDownload =
downloadStates.keys.filter { key ->
key != "all" && downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading
downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading
}
toDownload.forEach { key ->
handleDownloadAction(key)
Expand Down Expand Up @@ -214,7 +225,6 @@ class DataDownloadViewModel(
*/
fun checkAllForUpdates() {
downloadStates.keys.forEach { key ->
if (key == "all") return@forEach
// Only check languages that have been downloaded before.
if (downloadStates[key] == DownloadState.Completed) {
checkForUpdates(key)
Expand Down
Loading