Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
760813f
add: import & export buttons in SettingsScreen
mnjxclone Apr 25, 2026
c17da6d
update: add import and export functionality
mnjxclone Apr 25, 2026
daed80b
progress on a migration from raw SQL backup/restore to a JSON arch.
mnjxclone Apr 29, 2026
1d61653
update: JSON data export/import
mnjxclone May 3, 2026
e8bb260
fix: remove temporary logging
mnjxclone May 3, 2026
8e2aed6
fix: remove to-be-deprecated content
mnjxclone May 9, 2026
3bc0c36
fix: proper use of ioDispatcher
mnjxclone May 9, 2026
a6b6f97
fix: remove unused imports
mnjxclone May 9, 2026
eb46859
fix: remove redundant lib in libs.versions.toml
mnjxclone May 9, 2026
f1ee9b5
fix: repair build.gradle.kts
mnjxclone May 9, 2026
70c7dc0
fix: use standard naming conventions
mnjxclone May 9, 2026
cab0cc9
fix: use correct serializer for LocalDateTime
mnjxclone May 9, 2026
aab175e
fix: remove unused variables
mnjxclone May 9, 2026
703cfb7
fix: revert prev commit
mnjxclone May 9, 2026
3d0f7aa
fix: remove unnecessary upsert methods
mnjxclone May 9, 2026
3ecf5e0
fix: correct export/import pipeline (but allows duplicates)
mnjxclone May 9, 2026
e7f57bc
fix: convert toasts to snackbars
mnjxclone May 9, 2026
0d1f0c6
resolve merge conflicts with upstream
mnjxclone May 25, 2026
7d6d1cf
add: missing generic savePreferences function
mnjxclone May 25, 2026
3988156
add: datastore dependency
mnjxclone May 25, 2026
2240b70
fix: correct jvm version
mnjxclone May 25, 2026
67efb57
update: use dialogs instead of snackbars
mnjxclone May 25, 2026
d7000c9
fix: fix linted errors
mnjxclone May 25, 2026
29d6a42
fix: add proper migration logic
mnjxclone May 25, 2026
01a3c42
fix: fix typo and add animations to loading
mnjxclone May 25, 2026
edb8a62
fix: separated concerns regarding SettingsEvents
mnjxclone May 25, 2026
48d9855
fix: remove redundant datastore dependency
mnjxclone Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ dependencyLocking {

dependencies {

implementation(libs.androidx.compose.foundation.layout)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is added? It is redundant because it's already included in implementation(platform(libs.androidx.compose.bom))

Suggested change
implementation(libs.androidx.compose.foundation.layout)

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/org/librefit/db/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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<Workout>,
val exercises: List<Exercise>,
val sets: List<Set>,
val measurements: List<Measurement>,
val exerciseDCs: List<ExerciseDC>
)
3 changes: 3 additions & 0 deletions app/src/main/java/org/librefit/db/dao/DatasetDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ interface DatasetDao {
@Query("SELECT * FROM dataset ORDER BY name")
fun getDataset(): Flow<List<ExerciseDC>>

@Query("SELECT * FROM dataset ORDER BY name")
suspend fun getAllExerciseDCs(): List<ExerciseDC>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use standard naming conventions

Suggested change
suspend fun getAllExerciseDCs(): List<ExerciseDC>
suspend fun getDatasetOnce(): List<ExerciseDC>


@Query("SELECT * FROM dataset WHERE isCustomExercise")
fun getCustomExercises(): Flow<List<ExerciseDC>>

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/org/librefit/db/dao/MeasurementDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ interface MeasurementDao {
@Query("SELECT * FROM measurements ORDER BY date DESC")
fun getAllMeasurements(): Flow<List<Measurement>>

@Query("SELECT * FROM measurements ORDER BY date DESC")
suspend fun getAllMeasurementsForBackup(): List<Measurement>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use standard naming conventions

Suggested change
suspend fun getAllMeasurementsForBackup(): List<Measurement>
suspend fun getAllMeasurementsOnce(): List<Measurement>


@Query("SELECT * FROM measurements WHERE date <= :cutoff ORDER BY date DESC")
suspend fun getLastMeasurementByCutoff(cutoff: LocalDateTime): Measurement?
}
29 changes: 29 additions & 0 deletions app/src/main/java/org/librefit/db/dao/WorkoutDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +27,33 @@ import java.time.LocalDateTime

@Dao
interface WorkoutDao {
@Upsert
suspend fun upsertWorkouts(workouts: List<Workout>)

@Upsert
suspend fun upsertExercises(exercises: List<Exercise>)

@Upsert
suspend fun upsertSets(sets: List<Set>)

/**
* Returns a list of [org.librefit.db.entity.Workout]s ordered by their creation date
*/
@Query("SELECT * FROM workouts ORDER BY created")
suspend fun getAllWorkouts(): List<Workout>

/**
* Returns a list of [org.librefit.db.entity.Exercise]s for each workout
*/
@Query("SELECT * FROM exercises WHERE workoutId IN (:workoutIds)")
suspend fun getAllExercises(workoutIds: List<Long>): List<Exercise>

/**
* 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<Long>): List<Set>

/**
* Returns a flow that emits a stream of [org.librefit.db.entity.Workout]s filtered by [state]
*/
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/org/librefit/db/entity/Measurement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use this serializer LocalDateTimeSerializer ( https://github.com/LibreFitOrg/LibreFit/blob/main/app/src/main/java/org/librefit/db/entity/LocalDateTimeSerializer.kt ) instead:

Suggested change
@Contextual
@Serializable(with = LocalDateTimeSerializer::class)

val date: LocalDateTime = LocalDateTime.now(),
val notes: String = ""
)
137 changes: 137 additions & 0 deletions app/src/main/java/org/librefit/db/repository/ImportExportRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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.first
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 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(
private val db: AppDatabase,
@ApplicationContext private val context: Context
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@ApplicationContext private val context: Context
@param:ApplicationContext private val context: Context

) {
suspend fun exportTo(uri: Uri) = withContext(Dispatchers.IO) {
// 1. note the current db migration version
// 2. serialize the JSON
val workouts = db.getWorkoutDao().getAllWorkouts()

val workoutIds = workouts.map { it.id }
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()

val payload = ExportPayload(
version = 3,
data = ExportData(
workouts = workouts,
exercises = exercises,
sets = sets,
measurements = measurements,
exerciseDCs = exerciseDCs
)
)

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<Exercise>): List<Exercise> {
return exercises
.groupBy { it.workoutId }
.flatMap { (_, group) ->
group
.sortedBy { it.id }
.mapIndexed { index, exercise ->
exercise.copy(position = index)
}
}
}

suspend fun importFrom(uri: Uri) = withContext(Dispatchers.IO) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to inject the dispatcher ioDispatcher in the constructor marked with param:@IoDispatcher (whichis from DispatcherModule.kt ( https://github.com/LibreFitOrg/LibreFit/blob/main/app/src/main/java/org/librefit/di/DispatcherModule.kt#L27 )

Suggested change
suspend fun importFrom(uri: Uri) = withContext(Dispatchers.IO) {
suspend fun importFrom(uri: Uri) = withContext(ioDispatcher) {

val json = Json { ignoreUnknownKeys = true }

val payload = context.contentResolver.openInputStream(uri)?.use { input ->
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)

val text = input.bufferedReader().readText()
json.decodeFromString<ExportPayload>(text)
} ?: return@withContext

db.withTransaction {
Comment thread
odweta marked this conversation as resolved.
Outdated

val workoutDao = db.getWorkoutDao()
val measurementDao = db.getMeasurementDao()
val datasetDao = db.getDatasetDao()

// 1. UPSERT WORKOUTS
workoutDao.upsertWorkouts(payload.data.workouts)

// 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)

// 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)
}
}

// 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)
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/org/librefit/ui/models/UiBackupEvent.kt
Original file line number Diff line number Diff line change
@@ -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
}
Loading