diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3e548f4..ede0a79c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -233,6 +233,13 @@ dependencies { // ========================== implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21") + // ========================== + // API + // ========================== + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + // ========================== // Layout and UI // ========================== @@ -316,8 +323,8 @@ dependencies { api("com.google.code.gson:gson:2.13.1") api("com.github.bumptech.glide:glide:4.16.0") ksp("com.github.bumptech.glide:ksp:4.16.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("com.charleskorn.kaml:kaml:0.57.0") } tasks.register("moveFromi18n") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b70ae9e9..da738c60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + >>, +) + +/** + * Represents the contract details for the data response, + * including version, update timestamp, and field definitions. + */ +data class Contract( + @SerializedName("version") + val version: String, + @SerializedName("updated_at") + val updatedAt: String, + @SerializedName("fields") + val fields: Map>, +) diff --git a/app/src/main/java/be/scri/data/model/DataVersionResponse.kt b/app/src/main/java/be/scri/data/model/DataVersionResponse.kt new file mode 100644 index 00000000..d7b25243 --- /dev/null +++ b/app/src/main/java/be/scri/data/model/DataVersionResponse.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Represents the data version response for a specific language. + */ +data class DataVersionResponse( + @SerializedName("language") + val language: String, + @SerializedName("versions") + val versions: Map, +) diff --git a/app/src/main/java/be/scri/data/remote/ApiService.kt b/app/src/main/java/be/scri/data/remote/ApiService.kt new file mode 100644 index 00000000..698fc213 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/ApiService.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import be.scri.data.model.DataResponse +import be.scri.data.model.DataVersionResponse +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Defines the API service for fetching data and data version information. + */ +interface ApiService { + @GET("data/{lang}") + suspend fun getData( + @Path("lang") language: String, + ): DataResponse + + @GET("data-version/{lang}") + suspend fun getDataVersion( + @Path("lang") language: String, + ): DataVersionResponse +} diff --git a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt new file mode 100644 index 00000000..e382ef95 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +import be.scri.data.model.DataResponse + +/** + * Helper class for managing dynamic SQLite databases based on language. + * It creates tables and inserts data according to the provided DataResponse. + */ +class DynamicDbHelper( + context: Context, + language: String, +) : SQLiteOpenHelper(context, "$language.db", null, 1) { + override fun onCreate(db: SQLiteDatabase) { + // Tables are created dynamically via syncDatabase from API contract. + } + + override fun onUpgrade( + db: SQLiteDatabase, + old: Int, + new: Int, + ) { + // Dynamic schema updates are handled via syncDatabase. + } + + /** + * Synchronizes the database schema and data based on the provided DataResponse. + * @param response The data response containing the contract and data to be inserted. + */ + fun syncDatabase(response: DataResponse) { + val db = writableDatabase + + // 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 + } + + // Insert Data with Transaction. + db.beginTransaction() + try { + response.data.forEach { (tableName, rows) -> + + rows.forEach { row -> + val cv = ContentValues() + 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.setTransactionSuccessful() + } catch (e: SQLiteException) { + Log.e("SCRIBE_DB", "Error during insert: ${e.message}") + } finally { + db.endTransaction() + } + } +} diff --git a/app/src/main/java/be/scri/data/remote/RetrofitClient.kt b/app/src/main/java/be/scri/data/remote/RetrofitClient.kt new file mode 100644 index 00000000..9efcdad4 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/RetrofitClient.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +/** + * Singleton object to provide Retrofit client instance for API calls. + */ +object RetrofitClient { + private const val BASE_URL = "https://scribe-server.toolforge.org/api/v1/" + + val apiService: ApiService by lazy { + val retrofit = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + retrofit.create(ApiService::class.java) + } +} 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 d0678fba..159f344f 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -20,7 +20,7 @@ class ConjugateDataManager( * The returned map is structured by tense/mood, then by conjugation type (e.g., "Indicative Present"). * * @param language The language code (e.g., "EN", "SV") to determine the correct database. - * @param jsonData The data contract for the language, which defines the structure of conjugations. + * @param yamlData The data contract for the language, which defines the structure of conjugations. * @param word The specific verb to look up conjugations for. * * @return A nested map where the outer key is the tense group title @@ -29,20 +29,20 @@ class ConjugateDataManager( */ fun getTheConjugateLabels( language: String, - jsonData: DataContract?, + yamlData: DataContract?, word: String, ): MutableMap>>? { val finalOutput: MutableMap>> = mutableMapOf() - jsonData?.conjugations?.values?.forEach { tenseGroup -> + yamlData?.conjugations?.values?.forEach { tenseGroup -> val conjugateForms: MutableMap> = mutableMapOf() - tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> + tenseGroup.tenses.values.forEach { conjugationCategory -> val forms = - conjugationCategory.conjugationForms.values.map { form -> + conjugationCategory.tenseForms.values.map { form -> getTheValueForTheConjugateWord(word.lowercase(), form, language) } - conjugateForms[conjugationCategory.title] = forms + conjugateForms[conjugationCategory.tenseTitle] = forms } - finalOutput[tenseGroup.title] = conjugateForms + finalOutput[tenseGroup.sectionTitle] = conjugateForms } return if (finalOutput.isEmpty() || finalOutput.values.all { it.isEmpty() || it.values.all { forms -> forms.all { it.isEmpty() } } }) { null @@ -55,19 +55,19 @@ class ConjugateDataManager( * Extracts a unique set of all conjugation form keys (e.g., "1ps", "2ps", "participle") * from the data contract. * - * @param jsonData The data contract containing the conjugation structure. + * @param yamlData The data contract containing the conjugation structure. * @param word The base word, which is also added to the set. * * @return A `Set` of unique strings representing all possible conjugation form identifiers. */ fun extractConjugateHeadings( - jsonData: DataContract?, + yamlData: DataContract?, word: String, ): Set { val allFormKeys = mutableSetOf() - jsonData?.conjugations?.values?.forEach { tenseGroup -> - tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> - allFormKeys.addAll(conjugationCategory.conjugationForms.keys) + yamlData?.conjugations?.values?.forEach { tenseGroup -> + tenseGroup.tenses.values.forEach { conjugationCategory -> + allFormKeys.addAll(conjugationCategory.tenseForms.keys) } } allFormKeys.add(word) diff --git a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt index 085abc8b..9f6e3886 100644 --- a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt +++ b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt @@ -4,8 +4,9 @@ package be.scri.helpers.data import DataContract import android.content.Context import android.util.Log -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import com.charleskorn.kaml.YamlException import java.io.IOException /** @@ -16,29 +17,32 @@ class ContractDataLoader( private val context: Context, ) { /** - * Loads and deserializes a data contract from a JSON file in the assets folder. - * It gracefully handles file-not-found and JSON parsing errors by returning null. + * Loads and deserializes a data contract from a YAML file in the assets folder. + * It gracefully handles file-not-found and YAML parsing errors by returning null. * - * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.json"). + * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.yaml"). * * @return The decoded [DataContract] object if successful, or `null` * if the file does not exist or cannot be parsed. */ fun loadContract(language: String): DataContract? { - val contractName = "${language.lowercase()}.json" + val contractName = "${language.lowercase()}.yaml" Log.i("ContractDataLoader", "Attempting to load contract: $contractName") return try { - val jsonParser = Json { ignoreUnknownKeys = true } context.assets.open("data-contracts/$contractName").use { contractFile -> val content = contractFile.bufferedReader().readText() - jsonParser.decodeFromString(content) + val yaml = + Yaml( + configuration = YamlConfiguration(strictMode = false), + ) + yaml.decodeFromString(DataContract.serializer(), content) } } catch (e: IOException) { Log.e("ContractDataLoader", "Error loading contract file: $contractName. It may not exist.", e) null - } catch (e: SerializationException) { - Log.e("ContractDataLoader", "Error parsing JSON for contract: $contractName", e) + } catch (e: YamlException) { + Log.e("ContractDataLoader", "Error parsing YAML for contract: $contractName", e) null } } diff --git a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt index eb1c4d0a..0d4b84f2 100644 --- a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt @@ -17,16 +17,16 @@ class PluralFormsManager( * Retrieves a list of all known plural forms for a given language from the database. * * @param language The language code (e.g., "EN", "DE") to select the correct database. - * @param jsonData The data contract, which specifies the names of the columns containing plural forms. + * @param yamlData The data contract, which specifies the names of the columns containing plural forms. * * @return A [List] of all plural word forms, or `null` * if the operation fails or no plural columns are defined. */ fun getAllPluralForms( language: String, - jsonData: DataContract?, + yamlData: DataContract?, ): List? = - jsonData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> + yamlData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> fileManager.getLanguageDatabase(language)?.use { db -> queryAllPluralForms(db, pluralForms) } @@ -36,7 +36,7 @@ class PluralFormsManager( * Retrieves the specific plural representation for a single noun. * * @param language The language code to select the correct database. - * @param jsonData The data contract, which specifies the singular and plural column names. + * @param yamlData The data contract, which specifies the singular and plural column names. * @param noun The singular noun to find the plural for. * * @return A [Map] containing the singular noun as the key and @@ -44,10 +44,10 @@ class PluralFormsManager( */ fun getPluralRepresentation( language: String, - jsonData: DataContract?, + yamlData: DataContract?, noun: String, ): Map = - jsonData?.numbers?.let { numbers -> + yamlData?.numbers?.let { numbers -> val singularCol = numbers.keys.firstOrNull() val pluralCol = numbers.values.firstOrNull() diff --git a/app/src/main/java/be/scri/models/DataContract.kt b/app/src/main/java/be/scri/models/DataContract.kt index 6a4015d8..2241026f 100644 --- a/app/src/main/java/be/scri/models/DataContract.kt +++ b/app/src/main/java/be/scri/models/DataContract.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later @file:Suppress("ktlint:standard:kdoc") +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -13,7 +14,8 @@ import kotlinx.serialization.Serializable data class DataContract( val numbers: Map, val genders: Genders, - val conjugations: Map, + val conjugations: Map, + val translations: Translations, ) /** @@ -33,8 +35,8 @@ data class Genders( */ @Serializable data class TenseGroup( - val title: String = "", - val conjugationTypes: Map, + val sectionTitle: String = "", + val tenses: Map, ) /** @@ -42,6 +44,41 @@ data class TenseGroup( */ @Serializable data class ConjugationCategory( - val title: String = "", - val conjugationForms: Map = emptyMap(), + val tenseTitle: String = "", + val tenseForms: Map = emptyMap(), +) + +/** + * Represents the structure of translations for different word types. + */ +@Serializable +data class Translations( + val wordType: WordType, +) + +/** + * Represents the various parts of speech and their associated display values and section titles. + */ +@Serializable +data class WordType( + val sectionTitle: String, + val adjective: WordTypeEntry, + val adverb: WordTypeEntry, + val article: WordTypeEntry, + val conjunction: WordTypeEntry, + val noun: WordTypeEntry, + val postposition: WordTypeEntry, + val preposition: WordTypeEntry, + @SerialName("proper_noun") val properNoun: WordTypeEntry, + val pronoun: WordTypeEntry, + val verb: WordTypeEntry, +) + +/** + * Represents the display value and section title for a specific part of speech. + */ +@Serializable +data class WordTypeEntry( + val displayValue: String, + val sectionTitle: String, ) diff --git a/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt b/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt index 7446a804..fad200aa 100644 --- a/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt @@ -46,7 +46,7 @@ fun SelectTranslationSourceLanguageScreen( onBackNavigation: () -> Unit, onNavigateToDownloadData: () -> Unit, modifier: Modifier = Modifier, - onDownloadAction: (String) -> Unit = {}, + onDownloadAction: (String, Boolean) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) @@ -143,7 +143,7 @@ fun SelectTranslationSourceLanguageScreen( val downloadKey = currentLanguage.lowercase() // trigger the download action in the ViewModel. - onDownloadAction(downloadKey) + onDownloadAction(downloadKey, true) showDialog.value = false // Navigate to the download data screen. onNavigateToDownloadData() 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 e3a21942..0ca440bc 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 @@ -18,8 +18,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -44,6 +47,8 @@ import be.scri.ui.screens.settings.SettingsUtil * @param modifier Modifier for layout and styling. * @param downloadStates Map of language keys to their download states. * @param onDownloadAction Callback for download action when a language is selected and confirmed. + * @param initializeStates Callback to initialize download states for given languages. + * @param checkAllForUpdates Callback to check all languages for available updates. */ @Composable fun DownloadDataScreen( @@ -51,8 +56,11 @@ fun DownloadDataScreen( onNavigateToTranslation: (String) -> Unit, modifier: Modifier = Modifier, downloadStates: Map = emptyMap(), - onDownloadAction: (String) -> Unit = {}, + onDownloadAction: (String, Boolean) -> Unit = { _, _ -> }, + initializeStates: (List) -> Unit = {}, + checkAllForUpdates: () -> Unit, ) { + val currentInitializeStates by rememberUpdatedState(initializeStates) val scrollState = rememberScrollState() val checkForNewData = remember { mutableStateOf(false) } val regularlyUpdateData = remember { mutableStateOf(true) } @@ -89,6 +97,11 @@ fun DownloadDataScreen( } } + LaunchedEffect(languages) { + val keys = languages.map { it.first } + currentInitializeStates(keys) + } + ScribeBaseScreen( pageTitle = stringResource(R.string.i18n_app__global_download_data), lastPage = stringResource(R.string.i18n_app_installation_title), @@ -121,7 +134,12 @@ fun DownloadDataScreen( Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { CircleClickableItemComp( title = stringResource(R.string.i18n_app_download_menu_ui_update_data_check_new), - onClick = { checkForNewData.value = !checkForNewData.value }, + onClick = { + checkForNewData.value = !checkForNewData.value + if (checkForNewData.value) { + checkAllForUpdates() + } + }, isSelected = checkForNewData.value, ) @@ -168,7 +186,7 @@ fun DownloadDataScreen( if (currentStatus == DownloadState.Ready) { selectedLanguage.value = lang } else { - onDownloadAction(key) + onDownloadAction(key, false) } }, isDarkTheme = isDark, @@ -206,7 +224,7 @@ fun DownloadDataScreen( ), textChange = stringResource(R.string.i18n_app_download_menu_ui_translation_source_tooltip_change_language), onConfirm = { - onDownloadAction(key) + onDownloadAction(key, false) selectedLanguage.value = null }, onChange = { onNavigateToTranslation(languageId) }, 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 e3230e5d..38e09082 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 @@ -2,26 +2,76 @@ package be.scri.ui.screens.download +import android.app.Application +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import android.widget.Toast import androidx.compose.runtime.mutableStateMapOf -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import be.scri.data.remote.DynamicDbHelper +import be.scri.data.remote.RetrofitClient +import be.scri.helpers.LanguageMappingConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import java.io.IOException import java.time.LocalDate -private const val PLACEBO_SERVER_UPDATED_AT = "2025-01-10" -private const val PLACEBO_LOCAL_UPDATED_AT = "2025-01-01" - /** ViewModel to manage data download states and actions. */ -class DataDownloadViewModel : ViewModel() { +class DataDownloadViewModel( + application: Application, +) : AndroidViewModel(application) { val downloadStates = mutableStateMapOf() + private val downloadJobs = mutableMapOf() + private val prefs = getApplication().getSharedPreferences("scribe_prefs", Context.MODE_PRIVATE) + + /** + * Initializes the download states for the provided languages. + * + * @param languages A list of language keys to initialize states for. + */ + fun initializeStates(languages: List) { + languages.forEach { key -> + if (key == "all") return@forEach + if (downloadStates.containsKey(key)) return@forEach + + val langCode = + LanguageMappingConstants + .getLanguageAlias( + key.replaceFirstChar { it.uppercase() }, + ).lowercase() + + // Check if a timestamp exists in SharedPreferences. + val savedTimestamp = prefs.getString("last_update_$langCode", null) + + if (savedTimestamp != null) { + downloadStates[key] = DownloadState.Completed + } else { + downloadStates[key] = DownloadState.Ready + } + } + + // After initializing, check for updates on all Completed languages. + checkAllForUpdates() + } /** - * @return true if server data is newer than local data. + * Checks if an update is available by comparing local and server update timestamps. + * + * @param localUpdatedAt The last update timestamp stored locally. + * @param serverUpdatedAt The last update timestamp from the server. + * @return True if an update is available, false otherwise. */ private fun isUpdateAvailable( localUpdatedAt: String, serverUpdatedAt: String, ): Boolean { - val localDate = LocalDate.parse(localUpdatedAt) - val serverDate = LocalDate.parse(serverUpdatedAt) + val localDate = LocalDate.parse(localUpdatedAt.take(10)) + val serverDate = LocalDate.parse(serverUpdatedAt.take(10)) return serverDate.isAfter(localDate) } @@ -30,22 +80,154 @@ class DataDownloadViewModel : ViewModel() { * Handles the download action based on the current state. * * @param key The key identifying the download item. + * @param forceDownload If true, cancels any existing download and forces a new one. */ - fun handleDownloadAction(key: String) { + fun handleDownloadAction( + key: String, + forceDownload: Boolean = false, + ) { val currentState = downloadStates[key] ?: DownloadState.Ready - downloadStates[key] = - when (currentState) { - DownloadState.Ready -> DownloadState.Downloading - DownloadState.Downloading -> DownloadState.Completed - DownloadState.Completed -> - if (isUpdateAvailable(PLACEBO_LOCAL_UPDATED_AT, PLACEBO_SERVER_UPDATED_AT)) { - DownloadState.Update + val displayLang = key.replaceFirstChar { it.uppercase() } + if (forceDownload) { + downloadJobs[key]?.cancel() + } else { + if (currentState == DownloadState.Downloading) { + return + } + + if (currentState == DownloadState.Completed) { + Toast.makeText(getApplication(), "$displayLang data is already up to date", Toast.LENGTH_SHORT).show() + return + } + } + + // Set to downloading before hitting the network. + downloadStates[key] = DownloadState.Downloading + + val langCode = + LanguageMappingConstants + .getLanguageAlias( + key.replaceFirstChar { it.uppercase() }, + ).lowercase() + + val localLastUpdate = prefs.getString("last_update_$langCode", "1970-01-01") ?: "1970-01-01" + + // Store the job so we can cancel it later if needed. + downloadJobs[key] = + viewModelScope.launch(Dispatchers.IO) { + try { + // Fetch API. + val response = RetrofitClient.apiService.getData(langCode) + val serverLastUpdate = response.contract.updatedAt + + // Always download when forcing, or when update is available. + if (forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate)) { + val dbHelper = DynamicDbHelper(getApplication(), langCode) + dbHelper.syncDatabase(response) + + // Save timestamp. + prefs.edit().putString("last_update_$langCode", serverLastUpdate).apply() + + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + Toast.makeText(getApplication(), "Download $displayLang data finished!", Toast.LENGTH_SHORT).show() + } } else { - DownloadState.Completed + // Already up to date: Skip the DB work. + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + Toast.makeText(getApplication(), "Already up to date!", Toast.LENGTH_SHORT).show() + } } - DownloadState.Update -> DownloadState.Downloading + } catch (e: IOException) { + updateErrorState(key, "Network Error: ${e.message}") + } catch (e: SQLiteException) { + updateErrorState(key, "Database Error: ${e.message}") + } catch (e: HttpException) { + updateErrorState(key, "Server Error: ${e.code()}") + } finally { + // Clean up the job reference when done. + downloadJobs.remove(key) + } } } + + /** + * Checks for available updates using the data version API. + * Sets state to Update if server has newer data. + * + * @param key The key identifying the download item. + */ + fun checkForUpdates(key: String) { + val currentState = downloadStates[key] ?: DownloadState.Ready + if (currentState == DownloadState.Downloading) return + + val langCode = + LanguageMappingConstants + .getLanguageAlias(key.replaceFirstChar { it.uppercase() }) + .lowercase() + + val localLastUpdate = prefs.getString("last_update_$langCode", "1970-01-01") ?: "1970-01-01" + + viewModelScope.launch(Dispatchers.IO) { + try { + val response = RetrofitClient.apiService.getDataVersion(langCode) + + val hasUpdate = + response.versions.values.any { serverDate -> + isUpdateAvailable(localLastUpdate, serverDate) + } + + withContext(Dispatchers.Main) { + downloadStates[key] = + if (hasUpdate) { + DownloadState.Update + } else { + DownloadState.Completed + } + } + } catch (e: IOException) { + Log.w("DownloadVM", "Network error while checking updates for $key: ${e.message}") + } catch (e: HttpException) { + Log.w("DownloadVM", "Server error while checking updates for $key: ${e.code()}") + } catch (e: SQLiteException) { + Log.w("DownloadVM", "Database error while checking updates for $key: ${e.message}") + } + } + } + + /** + * Checks all languages for updates. + */ + 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) + } + } + } + + private suspend fun updateErrorState( + key: String, + message: String, + ) { + withContext(Dispatchers.Main) { + // Reset status so user can retry. + downloadStates[key] = DownloadState.Ready + Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show() + } + } + + /** + * Cancels all ongoing downloads. + */ + override fun onCleared() { + super.onCleared() + downloadJobs.values.forEach { it.cancel() } + downloadJobs.clear() + } } /** diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt index 16258974..b47eae15 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt @@ -26,7 +26,7 @@ object SettingsUtil { fun checkKeyboardInstallation(context: Context): Boolean { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - return imm.enabledInputMethodList.any { it.packageName == "be.scri.debug" } + return imm.enabledInputMethodList.any { it.packageName == context.packageName } } /**