diff --git a/app/src/main/assets/data/DELanguageData.sqlite b/app/src/main/assets/data/DELanguageData.sqlite deleted file mode 100644 index 7934ed73..00000000 Binary files a/app/src/main/assets/data/DELanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ENLanguageData.sqlite b/app/src/main/assets/data/ENLanguageData.sqlite deleted file mode 100644 index fe268aa8..00000000 Binary files a/app/src/main/assets/data/ENLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ESLanguageData.sqlite b/app/src/main/assets/data/ESLanguageData.sqlite deleted file mode 100644 index 81612e1e..00000000 Binary files a/app/src/main/assets/data/ESLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/FRLanguageData.sqlite b/app/src/main/assets/data/FRLanguageData.sqlite deleted file mode 100644 index 31c11f31..00000000 Binary files a/app/src/main/assets/data/FRLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ITLanguageData.sqlite b/app/src/main/assets/data/ITLanguageData.sqlite deleted file mode 100644 index ca3f2832..00000000 Binary files a/app/src/main/assets/data/ITLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/PTLanguageData.sqlite b/app/src/main/assets/data/PTLanguageData.sqlite deleted file mode 100644 index 7e30543e..00000000 Binary files a/app/src/main/assets/data/PTLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/RULanguageData.sqlite b/app/src/main/assets/data/RULanguageData.sqlite deleted file mode 100644 index 6dffd162..00000000 Binary files a/app/src/main/assets/data/RULanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/SVLanguageData.sqlite b/app/src/main/assets/data/SVLanguageData.sqlite deleted file mode 100644 index 393cce0b..00000000 Binary files a/app/src/main/assets/data/SVLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt index e382ef95..56c57bed 100644 --- a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt +++ b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt @@ -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. } @@ -36,28 +36,26 @@ 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() @@ -65,6 +63,7 @@ class DynamicDbHelper( Log.e("SCRIBE_DB", "Error during insert: ${e.message}") } finally { db.endTransaction() + db.close() } } } diff --git a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt index 52b12167..8b099063 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -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 + } } /** diff --git a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt index d55e11ad..1a208841 100644 --- a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt @@ -17,6 +17,7 @@ class AutoSuggestionDataManager( val suggestionMap = HashMap>() 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) { diff --git a/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt b/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt index 7be20938..0fd881e2 100644 --- a/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt @@ -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) + } } } } diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index e4b93db3..3b97d8eb 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -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) } diff --git a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt index 0c02bcfc..f36c9701 100644 --- a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt @@ -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) diff --git a/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt index 1750e154..884216de 100644 --- a/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt @@ -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 diff --git a/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt new file mode 100644 index 00000000..5ed412a3 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt @@ -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 + } diff --git a/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt b/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt index 386625f3..9c99bd60 100644 --- a/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt @@ -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 @@ -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 @@ -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 = 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()) { @@ -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) } @@ -206,7 +198,6 @@ fun DownloadDataScreen( } else { LanguagesListSection( languages = languages, - allLanguagesState = allLanguagesState, downloadStates = downloadStates, onLanguageSelect = { selectedLanguage.value = it }, onDownloadAll = onDownloadAll, @@ -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, - allLanguagesState: DownloadState, downloadStates: Map, onLanguageSelect: (LanguageItem) -> Unit, onDownloadAll: () -> Unit, @@ -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) diff --git a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt index 7d2255bb..81470ae8 100644 --- a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt @@ -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 @@ -26,6 +28,7 @@ class DataDownloadViewModel( application: Application, ) : AndroidViewModel(application) { val downloadStates = mutableStateMapOf() + private val downloadSemaphore = kotlinx.coroutines.sync.Semaphore(2) private val downloadJobs = mutableMapOf() private val prefs = getApplication().getSharedPreferences("scribe_prefs", Context.MODE_PRIVATE) @@ -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. @@ -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) } } @@ -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) @@ -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)