From acac24928675811b17b0cb7bf385967596fccb9e Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Fri, 8 May 2026 01:45:41 +0530 Subject: [PATCH] Implement Conjugate App Scribe-Server data download functionality (#565) - Create ConjugateDataDownloadViewModel for verb-specific downloads - Add ConjugateDynamicDbHelper for verb-only database creation - Update DatabaseFileManager with getConjugateDatabase() method - Modify ConjugateDataManager to use conjugate-specific databases - Update App.kt to integrate conjugate download actions - Implement separate state tracking for conjugate vs keyboard apps - Support verb-only downloads with smaller database files - Maintain shared API infrastructure while filtering for verbs Resolves: #565 --- app/src/main/java/be/scri/App.kt | 26 +- .../data/remote/ConjugateDynamicDbHelper.kt | 82 +++++ .../be/scri/helpers/DatabaseFileManager.kt | 25 ++ .../scri/helpers/data/ConjugateDataManager.kt | 4 +- .../ConjugateDataDownloadViewModel.kt | 294 ++++++++++++++++++ 5 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/be/scri/data/remote/ConjugateDynamicDbHelper.kt create mode 100644 app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt diff --git a/app/src/main/java/be/scri/App.kt b/app/src/main/java/be/scri/App.kt index 4131eaa49..223f0a357 100644 --- a/app/src/main/java/be/scri/App.kt +++ b/app/src/main/java/be/scri/App.kt @@ -43,6 +43,7 @@ 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.ConjugateDataDownloadViewModel import be.scri.ui.screens.download.ConjugateDownloadDataScreen import be.scri.ui.screens.download.DataDownloadViewModel import be.scri.ui.screens.download.DownloadActions @@ -85,6 +86,7 @@ fun ScribeApp( isIncreaseTextSize: Boolean, modifier: Modifier = Modifier, downloadViewModel: DataDownloadViewModel = viewModel(), + conjugateDownloadViewModel: ConjugateDataDownloadViewModel = viewModel(), ) { val coroutineScope = rememberCoroutineScope() val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -107,6 +109,26 @@ fun ScribeApp( cancelCheckForNewData = downloadViewModel::cancelCheckForNewData, ) + // Conjugate-specific download actions + val conjugateDownloadStates = conjugateDownloadViewModel.downloadStates + val onConjugateDownloadAction = conjugateDownloadViewModel::handleDownloadAction + val onConjugateDownloadAll = conjugateDownloadViewModel::handleDownloadAllLanguages + val initializeConjugateStates = conjugateDownloadViewModel::initializeStates + val conjugateDownloadActions = + DownloadActions( + downloadStates = conjugateDownloadStates, + onDownloadAction = onConjugateDownloadAction, + onDownloadAll = onConjugateDownloadAll, + initializeStates = initializeConjugateStates, + ) + val conjugateCheckUpdateState by conjugateDownloadViewModel.checkUpdateState.collectAsState() + val conjugateCheckUpdateActions = + CheckUpdateActions( + checkUpdateState = conjugateCheckUpdateState, + checkForNewData = conjugateDownloadViewModel::checkForNewData, + cancelCheckForNewData = conjugateDownloadViewModel::cancelCheckForNewData, + ) + val screens = remember(context) { BottomBarScreen.getScreens() } ScribeTheme( useDarkTheme = isDarkTheme, @@ -265,8 +287,8 @@ fun ScribeApp( navController.popBackStack() }, isDarkTheme = isDarkTheme, - downloadActions = downloadActions, - checkUpdateActions = checkUpdateActions, + downloadActions = conjugateDownloadActions, + checkUpdateActions = conjugateCheckUpdateActions, modifier = Modifier.padding(innerPadding), ) } diff --git a/app/src/main/java/be/scri/data/remote/ConjugateDynamicDbHelper.kt b/app/src/main/java/be/scri/data/remote/ConjugateDynamicDbHelper.kt new file mode 100644 index 000000000..8dd57eead --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/ConjugateDynamicDbHelper.kt @@ -0,0 +1,82 @@ +// 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 for conjugate data (verbs only). + * It creates only the verbs table and inserts verb data according to the provided DataResponse. + */ +class ConjugateDynamicDbHelper( + context: Context, + language: String, +) : SQLiteOpenHelper(context, "${language.uppercase()}ConjugateData.sqlite", null, 1) { + override fun onCreate(db: SQLiteDatabase) { + // Tables are created dynamically via syncConjugateDatabase from API contract. + } + + override fun onUpgrade( + db: SQLiteDatabase, + old: Int, + new: Int, + ) { + // Dynamic schema updates are handled via syncConjugateDatabase. + } + + /** + * Synchronizes the conjugate database schema and data based on the provided DataResponse. + * Only creates the verbs table and inserts verb data, ignoring other data types. + * @param response The data response containing the contract and data to be inserted. + */ + fun syncConjugateDatabase(response: DataResponse) { + val db = writableDatabase + try { + db.beginTransaction() + + // Check if verbs table exists in the contract + val verbsColumns = response.contract.fields["verbs"] + if (verbsColumns != null) { + // Create verbs table + val colDefinition = verbsColumns.keys.joinToString(", ") { "$it TEXT" } + db.execSQL("DROP TABLE IF EXISTS verbs") + db.execSQL( + "CREATE TABLE verbs " + + "(id INTEGER PRIMARY KEY AUTOINCREMENT, $colDefinition)", + ) + + // Insert verbs data + val verbsData = response.data["verbs"] + if (verbsData != null) { + val cv = ContentValues() + verbsData.forEach { row -> + cv.clear() + row.forEach { (key, value) -> + cv.put(key, value?.toString() ?: "") + } + db.insert("verbs", null, cv) + } + Log.i("CONJUGATE_DB", "Successfully synced ${verbsData.size} verb records for ${response.language}") + } else { + Log.w("CONJUGATE_DB", "No verbs data found in response for ${response.language}") + } + } else { + Log.e("CONJUGATE_DB", "No verbs table found in contract for ${response.language}") + } + + db.setTransactionSuccessful() + } catch (e: SQLiteException) { + Log.e("CONJUGATE_DB", "Error during conjugate database sync: ${e.message}") + throw e + } 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 8b099063c..c9f98e8a9 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -63,6 +63,31 @@ class DatabaseFileManager( return getDatabase(dbName, "data/$dbName") } + /** + * Retrieves a read-only [SQLiteDatabase] instance for conjugate data (verbs only). + * This database is created specifically for the Conjugate app and contains only verb data. + * + * @param language The language code (e.g., "DE", "FR") used to determine the database filename. + * + * @return An open, read-only [SQLiteDatabase] instance, or `null` on failure. + */ + fun getConjugateDatabase(language: String): SQLiteDatabase? { + val dbName = "${language}ConjugateData.sqlite" + val dbFile = context.getDatabasePath(dbName) + + if (!dbFile.exists()) { + Log.w(TAG, "Conjugate database $dbName not found. User needs to download conjugate data first") + return null + } + + return try { + SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: SQLiteException) { + Log.e(TAG, "Failed to open conjugate database $dbName", e) + null + } + } + /** * A generic function to get a database. It ensures the database file exists in the app's * private storage (copying it from assets if necessary) and then opens a read-only connection. 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 3b97d8ebe..895aac28e 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -91,7 +91,7 @@ class ConjugateDataManager( language: String, ): String { if (form.isNullOrEmpty()) return "" - return fileManager.getLanguageDatabase(language)?.use { db -> + return fileManager.getConjugateDatabase(language)?.use { db -> if (!db.tableExists("verbs")) { return "" } @@ -170,7 +170,7 @@ class ConjugateDataManager( val targetForm = words.first() - val db = fileManager.getLanguageDatabase(language = language) + val db = fileManager.getConjugateDatabase(language = language) var auxResult = "" val auxCursor = diff --git a/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt b/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt new file mode 100644 index 000000000..ed097cb03 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +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.AndroidViewModel +import androidx.lifecycle.viewModelScope +import be.scri.data.remote.ConjugateDynamicDbHelper +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.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import retrofit2.HttpException +import java.io.IOException +import java.time.LocalDate + +/** ViewModel to manage conjugate data download states and actions. */ +class ConjugateDataDownloadViewModel( + application: Application, +) : AndroidViewModel(application) { + val downloadStates = mutableStateMapOf() + private val downloadSemaphore = kotlinx.coroutines.sync.Semaphore(1) + private val downloadJobs = mutableMapOf() + private val prefs = getApplication().getSharedPreferences("scribe_conjugate_prefs", Context.MODE_PRIVATE) + private val _checkUpdateState = MutableStateFlow(CheckUpdateState.Idle) + val checkUpdateState = _checkUpdateState.asStateFlow() + + private var checkUpdateJob: Job? = null + + /** + * 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 (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_conjugate_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() + _checkUpdateState.value = CheckUpdateState.Idle + } + + /** + * 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.take(10)) + val serverDate = LocalDate.parse(serverUpdatedAt.take(10)) + + return serverDate.isAfter(localDate) + } + + /** + * Handles the download action for conjugate data (verbs only). + * + * @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, + forceDownload: Boolean = false, + ) { + val currentState = downloadStates[key] ?: DownloadState.Ready + 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 conjugate 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_conjugate_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) { + downloadSemaphore.acquire() + try { + // Fetch API. + val response = + withTimeout(30_000) { + 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 = ConjugateDynamicDbHelper(getApplication(), langCode) + dbHelper.syncConjugateDatabase(response) + + // Save timestamp. + prefs.edit().putString("last_conjugate_update_$langCode", serverLastUpdate).apply() + + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + Toast.makeText(getApplication(), "Download $displayLang conjugate data finished!", Toast.LENGTH_SHORT).show() + } + } else { + // 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() + } + } + } 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()}") + } catch (e: TimeoutCancellationException) { + updateErrorState(key, "Download timed out") + throw e + } finally { + // Clean up the job reference when done. + downloadSemaphore.release() + downloadJobs.remove(key) + } + } + } + + /** + * Handles the "All languages" download action by initiating downloads for all languages that are not already completed or downloading. + */ + fun handleDownloadAllLanguages() { + val toDownload = + downloadStates.keys.filter { key -> + downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading + } + toDownload.forEach { key -> + handleDownloadAction(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. + */ + private suspend 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_conjugate_update_$langCode", "1970-01-01") ?: "1970-01-01" + + 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("ConjugateDownloadVM", "Network error while checking updates for $key: ${e.message}") + } catch (e: HttpException) { + Log.w("ConjugateDownloadVM", "Server error while checking updates for $key: ${e.code()}") + } catch (e: SQLiteException) { + Log.w("ConjugateDownloadVM", "Database error while checking updates for $key: ${e.message}") + } + } + + /** + * Checks all languages for updates. + */ + private fun checkAllForUpdates() { + downloadStates.keys.forEach { key -> + // Only check languages that have been downloaded before. + if (downloadStates[key] == DownloadState.Completed) { + viewModelScope.launch { checkForUpdates(key) } + } + } + } + + /** + * Checks for new data updates for all completed languages. + */ + fun checkForNewData() { + checkUpdateJob?.cancel() + + val keysToCheck = downloadStates.keys.filter { downloadStates[it] == DownloadState.Completed } + + if (keysToCheck.isEmpty()) { + _checkUpdateState.value = CheckUpdateState.Idle + return + } + + _checkUpdateState.value = CheckUpdateState.Checking + + checkUpdateJob = + viewModelScope.launch { + coroutineScope { + keysToCheck.forEach { key -> launch { checkForUpdates(key) } } + } + _checkUpdateState.value = CheckUpdateState.Done + } + } + + /** + * Cancels the ongoing check for available updates. + */ + fun cancelCheckForNewData() { + checkUpdateJob?.cancel() + checkUpdateJob = null + _checkUpdateState.value = CheckUpdateState.Idle + } + + /** + * Updates the error state for a given key and shows a toast message. + * + * @param key The key identifying the download item. + * @param message The error message to display in the toast. + */ + 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() + } +}