From 760813f61d7cd640e62ee3584a1be569876d3b36 Mon Sep 17 00:00:00 2001 From: odweta Date: Sat, 25 Apr 2026 15:53:42 +0200 Subject: [PATCH 01/26] add: import & export buttons in SettingsScreen --- .../ui/screens/settings/SettingsScreen.kt | 28 +++++++++++++++++-- .../settings/SettingsScreenViewModel.kt | 8 ++++++ app/src/main/res/drawable/ic_backup.xml | 24 ++++++++++++++++ app/src/main/res/drawable/ic_restore.xml | 24 ++++++++++++++++ app/src/main/res/values/strings.xml | 4 +++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_backup.xml create mode 100644 app/src/main/res/drawable/ic_restore.xml diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt index 11a74444..e5397376 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt @@ -105,7 +105,9 @@ fun SettingsScreen( isSupporter = isSupporter, isWorkoutHeaderSticky = isWorkoutHeaderSticky, updatePreferences = viewModel::updatePreferences, - saveBooleanValue = viewModel::savePreference + saveBooleanValue = viewModel::savePreference, + backupExport = viewModel::backupExport, + backupImport = viewModel::backupImport ) } @@ -121,7 +123,9 @@ private fun SettingsScreenContent( isSupporter: Boolean, isWorkoutHeaderSticky: Boolean, updatePreferences: (List) -> Unit, - saveBooleanValue: (Preferences.Key, value: Boolean) -> Unit + saveBooleanValue: (Preferences.Key, value: Boolean) -> Unit, + backupExport: () -> Unit, + backupImport: () -> Unit ) { LibreFitScaffold( title = AnnotatedString(stringResource(id = R.string.settings)), @@ -228,6 +232,24 @@ private fun SettingsScreenContent( settingName = stringResource(R.string.stick_status_bar) ) } + + item { + SettingItem( + onClick = backupExport, + icon = painterResource(R.drawable.ic_backup), + settingName = stringResource(id = R.string.export_data), + settingDesc = stringResource(R.string.export_data_desc) + ) + } + + item { + SettingItem( + onClick = backupImport, + icon = painterResource(R.drawable.ic_restore), + settingName = stringResource(id = R.string.import_data), + settingDesc = stringResource(R.string.import_data_desc) + ) + } } } } @@ -331,6 +353,8 @@ fun SettingsScreenPreview() { } } }, + backupExport = {}, + backupImport = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt index 9a6b4e36..b649422e 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt @@ -90,4 +90,12 @@ class SettingsScreenViewModel @Inject constructor( ) } } + + fun backupImport() { + + } + + fun backupExport() { + + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml new file mode 100644 index 00000000..93e9cf0b --- /dev/null +++ b/app/src/main/res/drawable/ic_backup.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml new file mode 100644 index 00000000..a8914014 --- /dev/null +++ b/app/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b82dd0d..f6db3845 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,6 +263,10 @@ Stick status bar The status bar of ongoing workout will be stuck at the top of the screen The status bar of ongoing workout won\'t be stuck at the top of the screen + Export data + Export the current database + Import data + Import backed up data From c17da6dace38086510f810ca76cd329f1b091c5f Mon Sep 17 00:00:00 2001 From: odweta Date: Sat, 25 Apr 2026 18:11:37 +0200 Subject: [PATCH 02/26] update: add import and export functionality --- .../main/java/org/librefit/db/AppDatabase.kt | 25 +++++++ .../db/repository/ImportExportRepository.kt | 67 +++++++++++++++++++ .../org/librefit/ui/models/UiBackupEvent.kt | 7 ++ .../ui/screens/settings/SettingsScreen.kt | 36 ++++++++-- .../settings/SettingsScreenViewModel.kt | 35 ++++++++-- app/src/main/res/values/strings.xml | 2 + .../settings/SettingsScreenViewModelTest.kt | 6 +- 7 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt create mode 100644 app/src/main/java/org/librefit/ui/models/UiBackupEvent.kt diff --git a/app/src/main/java/org/librefit/db/AppDatabase.kt b/app/src/main/java/org/librefit/db/AppDatabase.kt index d6797a2b..b5add4ea 100644 --- a/app/src/main/java/org/librefit/db/AppDatabase.kt +++ b/app/src/main/java/org/librefit/db/AppDatabase.kt @@ -8,8 +8,10 @@ package org.librefit.db +import android.content.Context import androidx.room.AutoMigration import androidx.room.Database +import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration @@ -38,6 +40,29 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val NAME = "librefit_database" + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + NAME + ) + .addMigrations(MIGRATION_2_3) + .build() + + INSTANCE = instance + instance + } + } + + fun closeInstance() { + INSTANCE?.close() + INSTANCE = null + } + val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( diff --git a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt new file mode 100644 index 00000000..f38427c3 --- /dev/null +++ b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt @@ -0,0 +1,67 @@ +package org.librefit.db.repository + +import android.content.Context +import android.content.Intent +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.librefit.db.AppDatabase +import java.io.File +import javax.inject.Inject +import kotlin.system.exitProcess + +class ImportExportRepository @Inject constructor( + private val db: AppDatabase, + @ApplicationContext private val context: Context +) { + suspend fun exportTo(uri: Uri) = withContext(Dispatchers.IO) { + val sqliteDb = db.openHelper.writableDatabase + + val tempFile = File(context.cacheDir, "backup.db") + + val path = tempFile.absolutePath.replace("'", "''") + sqliteDb.execSQL("VACUUM INTO '$path'") + + context.contentResolver.openOutputStream(uri)?.use { out -> + tempFile.inputStream().use { input -> + input.copyTo(out) + } + } + + tempFile.delete() + } + + suspend fun importFrom(uri: Uri): Nothing = withContext(Dispatchers.IO) { + val dbFile = context.getDatabasePath(AppDatabase.NAME) + + AppDatabase.closeInstance() + + val tempFile = File(context.cacheDir, "restore.db") + + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val wal = File(dbFile.path + "-wal") + val shm = File(dbFile.path + "-shm") + + wal.delete() + shm.delete() + + tempFile.copyTo(dbFile, overwrite = true) + tempFile.delete() + + AppDatabase.getInstance(context) // Reinitialize with the new DB file + // restart app process cleanly + val intent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + + intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + + context.startActivity(intent) + exitProcess(0) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/models/UiBackupEvent.kt b/app/src/main/java/org/librefit/ui/models/UiBackupEvent.kt new file mode 100644 index 00000000..77956e97 --- /dev/null +++ b/app/src/main/java/org/librefit/ui/models/UiBackupEvent.kt @@ -0,0 +1,7 @@ +package org.librefit.ui.models + +sealed interface BackupEvent { + object BackupSuccess : BackupEvent + object RestoreSuccess : BackupEvent + data class Error(val message: String) : BackupEvent +} \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt index e5397376..68f586a1 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt @@ -8,7 +8,11 @@ package org.librefit.ui.screens.settings +import android.net.Uri import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement @@ -21,9 +25,12 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch 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 @@ -32,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -53,6 +61,7 @@ import org.librefit.ui.components.HeadlineText import org.librefit.ui.components.LibreFitLazyColumn import org.librefit.ui.components.LibreFitScaffold import org.librefit.ui.components.dialogs.PreferenceDialog +import org.librefit.ui.models.BackupEvent import org.librefit.ui.theme.LibreFitTheme import org.librefit.util.Formatter import kotlin.random.Random @@ -124,8 +133,8 @@ private fun SettingsScreenContent( isWorkoutHeaderSticky: Boolean, updatePreferences: (List) -> Unit, saveBooleanValue: (Preferences.Key, value: Boolean) -> Unit, - backupExport: () -> Unit, - backupImport: () -> Unit + backupExport: (Uri) -> Unit, + backupImport: (Uri) -> Unit ) { LibreFitScaffold( title = AnnotatedString(stringResource(id = R.string.settings)), @@ -234,8 +243,17 @@ private fun SettingsScreenContent( } item { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + uri?.let { backupExport(it) } + } + SettingItem( - onClick = backupExport, + onClick = { + val fileName = "librefit-backup.db" + launcher.launch(fileName) + }, icon = painterResource(R.drawable.ic_backup), settingName = stringResource(id = R.string.export_data), settingDesc = stringResource(R.string.export_data_desc) @@ -243,8 +261,18 @@ private fun SettingsScreenContent( } item { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { + backupImport(it) + } + } SettingItem( - onClick = backupImport, + onClick = { + launcher.launch(arrayOf("*/*")) + }, icon = painterResource(R.drawable.ic_restore), settingName = stringResource(id = R.string.import_data), settingDesc = stringResource(R.string.import_data_desc) diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt index b649422e..0419c397 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt @@ -8,28 +8,34 @@ package org.librefit.ui.screens.settings +import android.net.Uri import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.librefit.db.repository.ImportExportRepository import org.librefit.db.repository.UserPreferencesRepository import org.librefit.enums.userPreferences.DialogPreference import org.librefit.enums.userPreferences.Language import org.librefit.enums.userPreferences.ThemeMode +import org.librefit.ui.models.BackupEvent import javax.inject.Inject @HiltViewModel class SettingsScreenViewModel @Inject constructor( - private val userPreferences: UserPreferencesRepository + private val userPreferences: UserPreferencesRepository, + private val importExportRepository: ImportExportRepository ) : ViewModel() { val themeMode = userPreferences.themeMode val materialMode = userPreferences.materialMode @@ -91,11 +97,32 @@ class SettingsScreenViewModel @Inject constructor( } } - fun backupImport() { + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + fun backupExport(uri: Uri) { + viewModelScope.launch { + try { + importExportRepository.exportTo(uri) + } catch (e: Exception) { + // TODO: catch and show + _events.emit( + BackupEvent.Error(e.message ?: "Data backup export failed") + ) + } + } } - fun backupExport() { - + fun backupImport(uri: Uri) { + viewModelScope.launch { + try { + importExportRepository.importFrom(uri) + } catch (e: Exception) { + // TODO: catch and show + _events.emit( + BackupEvent.Error(e.message ?: "Data backup restore failed") + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6db3845..356f9932 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,8 +265,10 @@ The status bar of ongoing workout won\'t be stuck at the top of the screen Export data Export the current database + Data exported successfully Import data Import backed up data + Data imported successfully diff --git a/app/src/test/java/org/librefit/ui/screens/settings/SettingsScreenViewModelTest.kt b/app/src/test/java/org/librefit/ui/screens/settings/SettingsScreenViewModelTest.kt index e9ae00ca..c2a57582 100644 --- a/app/src/test/java/org/librefit/ui/screens/settings/SettingsScreenViewModelTest.kt +++ b/app/src/test/java/org/librefit/ui/screens/settings/SettingsScreenViewModelTest.kt @@ -18,11 +18,11 @@ import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Rule import org.junit.Test import org.librefit.MainDispatcherRule +import org.librefit.db.repository.ImportExportRepository import org.librefit.db.repository.UserPreferencesRepository import org.librefit.enums.userPreferences.Language import org.librefit.enums.userPreferences.ThemeMode @@ -35,6 +35,7 @@ class SettingsScreenViewModelTest { // The mock repository private lateinit var userPreferencesRepository: UserPreferencesRepository + private lateinit var importExportRepository: ImportExportRepository // The class under test private lateinit var viewModel: SettingsScreenViewModel @@ -56,6 +57,7 @@ class SettingsScreenViewModelTest { fun setUp() { // Arrange: Create a mock for the repository userPreferencesRepository = mockk() + importExportRepository = mockk() language = MutableStateFlow(Language.SYSTEM) themeMode = MutableStateFlow(ThemeMode.SYSTEM) keepScreenOn = MutableStateFlow(true) @@ -113,7 +115,7 @@ class SettingsScreenViewModelTest { } // Arrange: Create the ViewModel instance with the mock repository - viewModel = SettingsScreenViewModel(userPreferencesRepository) + viewModel = SettingsScreenViewModel(userPreferencesRepository, importExportRepository) } @Test From daed80b54d33f267ce717986949cf4c95c94c60f Mon Sep 17 00:00:00 2001 From: odweta Date: Wed, 29 Apr 2026 18:02:25 +0200 Subject: [PATCH 03/26] progress on a migration from raw SQL backup/restore to a JSON arch. --- .../db/converters/ImportExportConverter.kt | 20 +++++++++++ .../java/org/librefit/db/dao/WorkoutDao.kt | 13 +++++++ .../db/repository/ImportExportRepository.kt | 34 +++++++++++++------ 3 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt diff --git a/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt b/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt new file mode 100644 index 00000000..bdb7d090 --- /dev/null +++ b/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt @@ -0,0 +1,20 @@ +package org.librefit.db.converters + +import org.librefit.db.entity.Exercise +import org.librefit.db.entity.ExerciseDC +import org.librefit.db.entity.Measurement +import org.librefit.db.entity.Workout +import org.librefit.db.entity.Set + +data class ExportPayload( + val version: Int, + val data: ExportData +) + +data class ExportData( + val workouts: List, + val exercises: List, + val sets: List, + val measurements: List, + val exerciseDCs: List +) \ No newline at end of file diff --git a/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt b/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt index 185ec77a..de9a5005 100644 --- a/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt +++ b/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt @@ -25,6 +25,19 @@ import java.time.LocalDateTime @Dao interface WorkoutDao { + /** + * Returns a flow that emits a stream of [org.librefit.db.entity.Workout]s ordered by their + * creation date + */ + @Query("SELECT * FROM workouts ORDER BY created") + fun getAllWorkouts(): Flow> + + /** + * Returns a flow that emits a stream of [org.librefit.db.entity.Exercise]s for each workout + */ + @Query("SELECT * FROM exercises WHERE workoutId IN (:workoutIds)") + fun getAllExercises(workoutIds: List): Flow> + /** * Returns a flow that emits a stream of [org.librefit.db.entity.Workout]s filtered by [state] */ diff --git a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt index f38427c3..acb539d3 100644 --- a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt +++ b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt @@ -5,8 +5,13 @@ import android.content.Intent import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.withContext import org.librefit.db.AppDatabase +import org.librefit.db.converters.ExportData +import org.librefit.db.converters.ExportPayload import java.io.File import javax.inject.Inject import kotlin.system.exitProcess @@ -16,20 +21,27 @@ class ImportExportRepository @Inject constructor( @ApplicationContext private val context: Context ) { suspend fun exportTo(uri: Uri) = withContext(Dispatchers.IO) { - val sqliteDb = db.openHelper.writableDatabase - - val tempFile = File(context.cacheDir, "backup.db") - - val path = tempFile.absolutePath.replace("'", "''") - sqliteDb.execSQL("VACUUM INTO '$path'") + // 1. note the current db migration version + // 2. serialize the JSON + val workouts = db.getWorkoutDao().getAllWorkouts().first() + val workoutIds = workouts.map { it.id } + val exercises = db.getWorkoutDao().getAllExercises(workoutIds).first() + val payload = ExportPayload( + version = 3, + data = ExportData( + workouts = workouts, + exercises = exercises, + // TODO + sets = db.getWorkoutDao().getAllSets(), + measurements = db.getMeasurementDao().getAll(), + exerciseDCs = db.getDatasetDao().getAll() + ) + ) context.contentResolver.openOutputStream(uri)?.use { out -> - tempFile.inputStream().use { input -> - input.copyTo(out) - } + val json = /* your serializer, e.g. kotlinx.serialization */ + out.write(json.encodeToString(payload).toByteArray()) } - - tempFile.delete() } suspend fun importFrom(uri: Uri): Nothing = withContext(Dispatchers.IO) { From 1d61653e6df125fbf9a949cfc0b19af7e4f519d0 Mon Sep 17 00:00:00 2001 From: odweta Date: Sun, 3 May 2026 14:30:40 +0200 Subject: [PATCH 04/26] update: JSON data export/import --- app/build.gradle.kts | 1 + .../db/converters/ImportExportConverter.kt | 3 + .../java/org/librefit/db/dao/DatasetDao.kt | 3 + .../org/librefit/db/dao/MeasurementDao.kt | 3 + .../java/org/librefit/db/dao/WorkoutDao.kt | 26 +++- .../org/librefit/db/entity/Measurement.kt | 4 + .../db/repository/ImportExportRepository.kt | 121 ++++++++++++++---- .../ui/screens/settings/SettingsScreen.kt | 73 +++++++---- .../settings/SettingsScreenViewModel.kt | 9 +- gradle/libs.versions.toml | 2 + 10 files changed, 183 insertions(+), 62 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a237fa2e..eeb2610d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ dependencyLocking { dependencies { + implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt b/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt index bdb7d090..d947c47d 100644 --- a/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt +++ b/app/src/main/java/org/librefit/db/converters/ImportExportConverter.kt @@ -1,16 +1,19 @@ package org.librefit.db.converters +import kotlinx.serialization.Serializable import org.librefit.db.entity.Exercise import org.librefit.db.entity.ExerciseDC import org.librefit.db.entity.Measurement import org.librefit.db.entity.Workout import org.librefit.db.entity.Set +@Serializable data class ExportPayload( val version: Int, val data: ExportData ) +@Serializable data class ExportData( val workouts: List, val exercises: List, diff --git a/app/src/main/java/org/librefit/db/dao/DatasetDao.kt b/app/src/main/java/org/librefit/db/dao/DatasetDao.kt index 0aab9727..025d64ca 100644 --- a/app/src/main/java/org/librefit/db/dao/DatasetDao.kt +++ b/app/src/main/java/org/librefit/db/dao/DatasetDao.kt @@ -23,6 +23,9 @@ interface DatasetDao { @Query("SELECT * FROM dataset ORDER BY name") fun getDataset(): Flow> + @Query("SELECT * FROM dataset ORDER BY name") + suspend fun getAllExerciseDCs(): List + @Query("SELECT * FROM dataset WHERE isCustomExercise") fun getCustomExercises(): Flow> diff --git a/app/src/main/java/org/librefit/db/dao/MeasurementDao.kt b/app/src/main/java/org/librefit/db/dao/MeasurementDao.kt index c14bd7b4..f0f710f7 100644 --- a/app/src/main/java/org/librefit/db/dao/MeasurementDao.kt +++ b/app/src/main/java/org/librefit/db/dao/MeasurementDao.kt @@ -30,6 +30,9 @@ interface MeasurementDao { @Query("SELECT * FROM measurements ORDER BY date DESC") fun getAllMeasurements(): Flow> + @Query("SELECT * FROM measurements ORDER BY date DESC") + suspend fun getAllMeasurementsForBackup(): List + @Query("SELECT * FROM measurements WHERE date <= :cutoff ORDER BY date DESC") suspend fun getLastMeasurementByCutoff(cutoff: LocalDateTime): Measurement? } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt b/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt index de9a5005..5c70037f 100644 --- a/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt +++ b/app/src/main/java/org/librefit/db/dao/WorkoutDao.kt @@ -14,8 +14,10 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update +import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import org.librefit.db.entity.Exercise +import org.librefit.db.entity.Measurement import org.librefit.db.entity.Set import org.librefit.db.entity.Workout import org.librefit.db.relations.ExerciseWithSets @@ -25,18 +27,32 @@ import java.time.LocalDateTime @Dao interface WorkoutDao { + @Upsert + suspend fun upsertWorkouts(workouts: List) + + @Upsert + suspend fun upsertExercises(exercises: List) + + @Upsert + suspend fun upsertSets(sets: List) + /** - * Returns a flow that emits a stream of [org.librefit.db.entity.Workout]s ordered by their - * creation date + * Returns a list of [org.librefit.db.entity.Workout]s ordered by their creation date */ @Query("SELECT * FROM workouts ORDER BY created") - fun getAllWorkouts(): Flow> + suspend fun getAllWorkouts(): List /** - * Returns a flow that emits a stream of [org.librefit.db.entity.Exercise]s for each workout + * Returns a list of [org.librefit.db.entity.Exercise]s for each workout */ @Query("SELECT * FROM exercises WHERE workoutId IN (:workoutIds)") - fun getAllExercises(workoutIds: List): Flow> + suspend fun getAllExercises(workoutIds: List): List + + /** + * Returns a list of [org.librefit.db.entity.Set]s for each exercise + */ + @Query("SELECT * FROM sets WHERE exerciseId IN (:exerciseIds)") + suspend fun getAllSets(exerciseIds: List): List /** * Returns a flow that emits a stream of [org.librefit.db.entity.Workout]s filtered by [state] diff --git a/app/src/main/java/org/librefit/db/entity/Measurement.kt b/app/src/main/java/org/librefit/db/entity/Measurement.kt index c295e4a4..59b1f033 100644 --- a/app/src/main/java/org/librefit/db/entity/Measurement.kt +++ b/app/src/main/java/org/librefit/db/entity/Measurement.kt @@ -12,14 +12,18 @@ import androidx.annotation.FloatRange import androidx.annotation.IntRange import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable import java.time.LocalDateTime @Entity(tableName = "measurements") +@Serializable data class Measurement( @PrimaryKey(autoGenerate = true) val id: Long = 0L, @get:FloatRange(0.0, 300.0) val bodyWeight: Double = 0.0, @get:IntRange(0, 100) val bodyFatPercentage: Int = 0, @get:IntRange(0, 100) val muscleMassPercentage: Int = 0, + @Contextual val date: LocalDateTime = LocalDateTime.now(), val notes: String = "" ) diff --git a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt index acb539d3..83cdff27 100644 --- a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt +++ b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt @@ -3,17 +3,21 @@ package org.librefit.db.repository import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log +import androidx.room.withTransaction import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule import org.librefit.db.AppDatabase import org.librefit.db.converters.ExportData import org.librefit.db.converters.ExportPayload -import java.io.File import javax.inject.Inject +import org.librefit.db.entity.Exercise +import org.librefit.db.entity.LocalDateTimeSerializer +import java.time.LocalDateTime import kotlin.system.exitProcess class ImportExportRepository @Inject constructor( @@ -23,50 +27,111 @@ class ImportExportRepository @Inject constructor( suspend fun exportTo(uri: Uri) = withContext(Dispatchers.IO) { // 1. note the current db migration version // 2. serialize the JSON - val workouts = db.getWorkoutDao().getAllWorkouts().first() + val workouts = db.getWorkoutDao().getAllWorkouts() + val workoutIds = workouts.map { it.id } - val exercises = db.getWorkoutDao().getAllExercises(workoutIds).first() + val exercises = db.getWorkoutDao().getAllExercises(workoutIds) + + val exerciseIds = exercises.map { it.id } + val sets = db.getWorkoutDao().getAllSets(exerciseIds) + + val measurements = db.getMeasurementDao().getAllMeasurementsForBackup() + + val exerciseDCs = db.getDatasetDao().getAllExerciseDCs() + + Log.d("EXPORT", "workouts=${workouts.size}") + Log.d("EXPORT", "exercises=${exercises.size}") + Log.d("EXPORT", "sets=${sets.size}") + val payload = ExportPayload( version = 3, data = ExportData( workouts = workouts, exercises = exercises, - // TODO - sets = db.getWorkoutDao().getAllSets(), - measurements = db.getMeasurementDao().getAll(), - exerciseDCs = db.getDatasetDao().getAll() + sets = sets, + measurements = measurements, + exerciseDCs = exerciseDCs ) ) - context.contentResolver.openOutputStream(uri)?.use { out -> - val json = /* your serializer, e.g. kotlinx.serialization */ - out.write(json.encodeToString(payload).toByteArray()) + val outputStream = context.contentResolver.openOutputStream(uri) + ?: error("Cannot open output stream for export URI") + + outputStream.use { out -> + val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + serializersModule = SerializersModule { + contextual(LocalDateTime::class, LocalDateTimeSerializer) + } + } + out.write(json.encodeToString(payload).toByteArray()) + out.flush() } + + outputStream.close() + } + + private fun normalizeExercises(exercises: List): List { + return exercises + .groupBy { it.workoutId } + .flatMap { (_, group) -> + group + .sortedBy { it.id } + .mapIndexed { index, exercise -> + exercise.copy(position = index) + } + } } - suspend fun importFrom(uri: Uri): Nothing = withContext(Dispatchers.IO) { - val dbFile = context.getDatabasePath(AppDatabase.NAME) + suspend fun importFrom(uri: Uri) = withContext(Dispatchers.IO) { + val json = Json { ignoreUnknownKeys = true } - AppDatabase.closeInstance() + val payload = context.contentResolver.openInputStream(uri)?.use { input -> + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) - val tempFile = File(context.cacheDir, "restore.db") + val text = input.bufferedReader().readText() + json.decodeFromString(text) + } ?: return@withContext - context.contentResolver.openInputStream(uri)?.use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } + Log.d("IMPORT", "workouts ids = ${payload.data.workouts.map { it.id }}") + Log.d("IMPORT", "exercises ids = ${payload.data.exercises.map { it.id }}") + + db.withTransaction { + + val workoutDao = db.getWorkoutDao() + val measurementDao = db.getMeasurementDao() + val datasetDao = db.getDatasetDao() - val wal = File(dbFile.path + "-wal") - val shm = File(dbFile.path + "-shm") + // 1. UPSERT WORKOUTS + workoutDao.upsertWorkouts(payload.data.workouts) - wal.delete() - shm.delete() + // 2. UPSERT EXERCISES + // normalizing because of the migration to V3 of the DB schema + val normalizedExercises = if (payload.version < 3) + normalizeExercises(payload.data.exercises) + else + payload.data.exercises + workoutDao.upsertExercises(normalizedExercises) - tempFile.copyTo(dbFile, overwrite = true) - tempFile.delete() + // 3. UPSERT SETS + workoutDao.upsertSets(payload.data.sets) + + // 4. UPSERT MEASUREMENTS + payload.data.measurements.forEach { + measurementDao.upsertMeasurement(it) + } + + // 5. UPSERT DATASETS + payload.data.exerciseDCs.forEach { + datasetDao.upsertExercise(it) + } + } - AppDatabase.getInstance(context) // Reinitialize with the new DB file // restart app process cleanly val intent = context.packageManager .getLaunchIntentForPackage(context.packageName) diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt index 68f586a1..edbf8a61 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreen.kt @@ -8,20 +8,28 @@ package org.librefit.ui.screens.settings +import android.content.Intent import android.net.Uri import android.os.Build +import android.util.Log import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -93,6 +101,8 @@ fun SettingsScreen( val isWorkoutHeaderSticky by viewModel.isWorkoutHeaderSticky.collectAsStateWithLifecycle() + val isImporting by viewModel.isImporting.collectAsStateWithLifecycle() + preferences?.let { PreferenceDialog( currentPreference = currentPreference, @@ -101,7 +111,18 @@ fun SettingsScreen( ) { viewModel.updatePreferences(null) } + } + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { viewModel.backupExport(it) } + } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.backupImport(it) } } SettingsScreenContent( @@ -115,8 +136,12 @@ fun SettingsScreen( isWorkoutHeaderSticky = isWorkoutHeaderSticky, updatePreferences = viewModel::updatePreferences, saveBooleanValue = viewModel::savePreference, - backupExport = viewModel::backupExport, - backupImport = viewModel::backupImport + onExportClicked = { + val fileName = "librefit-backup.json" + exportLauncher.launch(fileName) + }, + onImportClicked = { importLauncher.launch(arrayOf("*/*")) }, + isImporting = isImporting ) } @@ -133,8 +158,9 @@ private fun SettingsScreenContent( isWorkoutHeaderSticky: Boolean, updatePreferences: (List) -> Unit, saveBooleanValue: (Preferences.Key, value: Boolean) -> Unit, - backupExport: (Uri) -> Unit, - backupImport: (Uri) -> Unit + onExportClicked: () -> Unit, + onImportClicked: () -> Unit, + isImporting: Boolean ) { LibreFitScaffold( title = AnnotatedString(stringResource(id = R.string.settings)), @@ -243,17 +269,8 @@ private fun SettingsScreenContent( } item { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - uri?.let { backupExport(it) } - } - SettingItem( - onClick = { - val fileName = "librefit-backup.db" - launcher.launch(fileName) - }, + onClick = onExportClicked, icon = painterResource(R.drawable.ic_backup), settingName = stringResource(id = R.string.export_data), settingDesc = stringResource(R.string.export_data_desc) @@ -261,18 +278,8 @@ private fun SettingsScreenContent( } item { - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument() - ) { uri: Uri? -> - uri?.let { - backupImport(it) - } - } SettingItem( - onClick = { - launcher.launch(arrayOf("*/*")) - }, + onClick = onImportClicked, icon = painterResource(R.drawable.ic_restore), settingName = stringResource(id = R.string.import_data), settingDesc = stringResource(R.string.import_data_desc) @@ -280,6 +287,17 @@ private fun SettingsScreenContent( } } } + + if (isImporting) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -381,8 +399,9 @@ fun SettingsScreenPreview() { } } }, - backupExport = {}, - backupImport = {} + onExportClicked = {}, + onImportClicked = {}, + isImporting = false ) } } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt index 0419c397..00235e08 100644 --- a/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt +++ b/app/src/main/java/org/librefit/ui/screens/settings/SettingsScreenViewModel.kt @@ -9,6 +9,8 @@ package org.librefit.ui.screens.settings import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -45,6 +47,8 @@ class SettingsScreenViewModel @Inject constructor( val isSupporter = userPreferences.isSupporter val isWorkoutHeaderSticky = userPreferences.isWorkoutHeaderSticky + private val _isImporting = MutableStateFlow(false) + val isImporting = _isImporting.asStateFlow() fun savePreference(key: Preferences.Key, value: T) { viewModelScope.launch { @@ -105,7 +109,6 @@ class SettingsScreenViewModel @Inject constructor( try { importExportRepository.exportTo(uri) } catch (e: Exception) { - // TODO: catch and show _events.emit( BackupEvent.Error(e.message ?: "Data backup export failed") ) @@ -115,13 +118,15 @@ class SettingsScreenViewModel @Inject constructor( fun backupImport(uri: Uri) { viewModelScope.launch { + _isImporting.value = true try { importExportRepository.importFrom(uri) } catch (e: Exception) { - // TODO: catch and show _events.emit( BackupEvent.Error(e.message ?: "Data backup restore failed") ) + } finally { + _isImporting.value = false } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b96a094e..8e63cfad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ kotlinxCollectionsImmutable = "0.4.0" material = "1.14.0-beta01" coil = "3.4.0" reorderable = "3.1.0" +foundationLayout = "1.11.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.18.0" } @@ -65,6 +66,7 @@ kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotli material = { group = "com.google.android.material", name = "material", version.ref = "material" } coil = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } +androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From e8bb2608b697986d29f149fe323a01443254ffd4 Mon Sep 17 00:00:00 2001 From: odweta Date: Sun, 3 May 2026 15:24:17 +0200 Subject: [PATCH 05/26] fix: remove temporary logging --- .../org/librefit/db/repository/ImportExportRepository.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt index 83cdff27..37d9b33b 100644 --- a/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt +++ b/app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt @@ -39,10 +39,6 @@ class ImportExportRepository @Inject constructor( val exerciseDCs = db.getDatasetDao().getAllExerciseDCs() - Log.d("EXPORT", "workouts=${workouts.size}") - Log.d("EXPORT", "exercises=${exercises.size}") - Log.d("EXPORT", "sets=${sets.size}") - val payload = ExportPayload( version = 3, data = ExportData( @@ -98,9 +94,6 @@ class ImportExportRepository @Inject constructor( json.decodeFromString(text) } ?: return@withContext - Log.d("IMPORT", "workouts ids = ${payload.data.workouts.map { it.id }}") - Log.d("IMPORT", "exercises ids = ${payload.data.exercises.map { it.id }}") - db.withTransaction { val workoutDao = db.getWorkoutDao() From 8e2aed6b11bda497e19c43327672566c63f62759 Mon Sep 17 00:00:00 2001 From: odweta Date: Sat, 9 May 2026 11:05:44 +0200 Subject: [PATCH 06/26] fix: remove to-be-deprecated content --- .idea/gradle.xml | 2 +- .../java/org/librefit/db/repository/ImportExportRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 2504dc64..d124cf2a 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,7 +6,7 @@