1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-19 23:12:12 +02:00

Implement Automatic Database Backups

This commit is contained in:
oliexdev
2025-08-17 19:25:01 +02:00
parent 98cc2ba9f5
commit 8f7c15e0c5
14 changed files with 1118 additions and 458 deletions

View File

@@ -147,6 +147,8 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.worker)
implementation(libs.androidx.documentfile)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -19,6 +19,7 @@
</queries>
<application
android:name=".OpenScaleApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -54,6 +55,16 @@
android:exported="true"
android:permission="${applicationId}.READ_WRITE_DATA">
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@@ -50,44 +50,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Generates a default list of measurement types available in the application,
* resolving names from string resources.
* These types are intended for insertion into the database on the first app start.
*
* @param context The context used to access string resources.
* @return A list of [MeasurementType] objects.
*/
fun getDefaultMeasurementTypes(): List<MeasurementType> {
return listOf(
MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFF7E57C2.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isPinned = true, isEnabled = true, isOnRightYAxis = true),
MeasurementType(key = MeasurementTypeKey.BMI, unit = UnitType.NONE, color = 0xFFFFCA28.toInt(), icon = MeasurementTypeIcon.IC_BMI, isDerived = true, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.HIPS, unit = UnitType.CM, color = 0xFF5C6BC0.toInt(), icon = MeasurementTypeIcon.IC_HIPS, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, unit = UnitType.NONE, color = 0xFFD84315.toInt(), icon = MeasurementTypeIcon.IC_VISCERAL_FAT, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CHEST, unit = UnitType.CM, color = 0xFF8E24AA.toInt(), icon = MeasurementTypeIcon.IC_CHEST, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.THIGH, unit = UnitType.CM, color = 0xFFA1887F.toInt(), icon = MeasurementTypeIcon.IC_THIGH, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BICEPS, unit = UnitType.CM, color = 0xFFEC407A.toInt(), icon = MeasurementTypeIcon.IC_BICEPS, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.NECK, unit = UnitType.CM, color = 0xFFB0BEC5.toInt(), icon = MeasurementTypeIcon.IC_NECK, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_1, unit = UnitType.CM, color = 0xFFFFF59D.toInt(), icon = MeasurementTypeIcon.IC_CALIPER1, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_2, unit = UnitType.CM, color = 0xFFFFE082.toInt(), icon = MeasurementTypeIcon.IC_CALIPER2, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_3, unit = UnitType.CM, color = 0xFFFFCC80.toInt(), icon = MeasurementTypeIcon.IC_CALIPER3, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER, unit = UnitType.PERCENT, color = 0xFFFB8C00.toInt(), icon = MeasurementTypeIcon.IC_FAT_CALIPER, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BMR, unit = UnitType.KCAL, color = 0xFFAB47BC.toInt(), icon = MeasurementTypeIcon.IC_BMR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TDEE, unit = UnitType.KCAL, color = 0xFF26A69A.toInt(), icon = MeasurementTypeIcon.IC_TDEE, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF757575.toInt(), icon = MeasurementTypeIcon.IC_TIME, isEnabled = true)
)
}
/**
* The main entry point of the application.
@@ -99,21 +61,13 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity"
}
private lateinit var userSettingsRepository: UserSettingsRepository // Machen Sie es zur Property
private val appInstance: OpenScaleApp by lazy { application as OpenScaleApp }
private val userSettingsRepository: UserSettingsRepository by lazy { appInstance.userSettingsRepository }
private val databaseRepository: DatabaseRepository by lazy { appInstance.databaseRepository }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
userSettingsRepository = provideUserSettingsRepository(applicationContext)
// --- LogManager initializing ---
lifecycleScope.launch {
val isFileLoggingEnabled = runCatching { userSettingsRepository.isFileLoggingEnabled.first() }
.getOrElse { false }
LogManager.init(applicationContext, isFileLoggingEnabled)
LogManager.d(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled")
}
// --- Language initializing ---
lifecycleScope.launch {
userSettingsRepository.appLanguageCode.collectLatest { languageCode ->
@@ -139,30 +93,7 @@ class MainActivity : ComponentActivity() {
}
private fun initializeAndSetContent() {
val db = AppDatabase.getInstance(applicationContext)
val databaseRepository = DatabaseRepository(
database = db,
userDao = db.userDao(),
measurementDao = db.measurementDao(),
measurementValueDao = db.measurementValueDao(),
measurementTypeDao = db.measurementTypeDao()
)
// --- Measurement Types initializing ---
CoroutineScope(Dispatchers.IO).launch {
val isActuallyFirstStart = userSettingsRepository.isFirstAppStart.first()
LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isActuallyFirstStart")
if (isActuallyFirstStart) {
LogManager.i(TAG, "First app start detected. Inserting default measurement types...")
val defaultTypesToInsert = getDefaultMeasurementTypes()
db.measurementTypeDao().insertAll(defaultTypesToInsert)
userSettingsRepository.setFirstAppStartCompleted(false)
LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.")
} else {
LogManager.d(TAG, "Not the first app start. Default data should already exist.")
}
}
LogManager.d(TAG, "Initializing and setting content.")
enableEdgeToEdge()
setContent {

View File

@@ -0,0 +1,152 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale
import android.app.Application
import android.util.Log
import androidx.work.Configuration
import com.health.openscale.core.data.InputFieldType
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.UnitType
import com.health.openscale.core.database.AppDatabase
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.database.UserSettingsRepository
import com.health.openscale.core.database.provideUserSettingsRepository
import com.health.openscale.core.utils.LogManager
import com.health.openscale.core.worker.TaskWorkerFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Generates a default list of measurement types available in the application,
* resolving names from string resources.
* These types are intended for insertion into the database on the first app start.
*
* @param context The context used to access string resources.
* @return A list of [MeasurementType] objects.
*/
fun getDefaultMeasurementTypes(): List<MeasurementType> {
return listOf(
MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFF7E57C2.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isPinned = true, isEnabled = true, isOnRightYAxis = true),
MeasurementType(key = MeasurementTypeKey.BMI, unit = UnitType.NONE, color = 0xFFFFCA28.toInt(), icon = MeasurementTypeIcon.IC_BMI, isDerived = true, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.HIPS, unit = UnitType.CM, color = 0xFF5C6BC0.toInt(), icon = MeasurementTypeIcon.IC_HIPS, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, unit = UnitType.NONE, color = 0xFFD84315.toInt(), icon = MeasurementTypeIcon.IC_VISCERAL_FAT, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CHEST, unit = UnitType.CM, color = 0xFF8E24AA.toInt(), icon = MeasurementTypeIcon.IC_CHEST, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.THIGH, unit = UnitType.CM, color = 0xFFA1887F.toInt(), icon = MeasurementTypeIcon.IC_THIGH, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BICEPS, unit = UnitType.CM, color = 0xFFEC407A.toInt(), icon = MeasurementTypeIcon.IC_BICEPS, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.NECK, unit = UnitType.CM, color = 0xFFB0BEC5.toInt(), icon = MeasurementTypeIcon.IC_NECK, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_1, unit = UnitType.CM, color = 0xFFFFF59D.toInt(), icon = MeasurementTypeIcon.IC_CALIPER1, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_2, unit = UnitType.CM, color = 0xFFFFE082.toInt(), icon = MeasurementTypeIcon.IC_CALIPER2, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_3, unit = UnitType.CM, color = 0xFFFFCC80.toInt(), icon = MeasurementTypeIcon.IC_CALIPER3, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER, unit = UnitType.PERCENT, color = 0xFFFB8C00.toInt(), icon = MeasurementTypeIcon.IC_FAT_CALIPER, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BMR, unit = UnitType.KCAL, color = 0xFFAB47BC.toInt(), icon = MeasurementTypeIcon.IC_BMR, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TDEE, unit = UnitType.KCAL, color = 0xFF26A69A.toInt(), icon = MeasurementTypeIcon.IC_TDEE, isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF757575.toInt(), icon = MeasurementTypeIcon.IC_TIME, isEnabled = true)
)
}
class OpenScaleApp : Application(), Configuration.Provider {
companion object {
private const val TAG = "OpenScaleApp"
}
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val userSettingsRepository: UserSettingsRepository by lazy {
provideUserSettingsRepository(applicationContext)
}
val databaseRepository: DatabaseRepository by lazy {
val db = AppDatabase.getInstance(applicationContext)
DatabaseRepository(
database = db,
userDao = db.userDao(),
measurementDao = db.measurementDao(),
measurementValueDao = db.measurementValueDao(),
measurementTypeDao = db.measurementTypeDao()
)
}
override fun onCreate() {
super.onCreate()
initializeLogging()
initializeDefaultData()
}
private fun initializeLogging() {
applicationScope.launch {
val isFileLoggingEnabled = try {
userSettingsRepository.isFileLoggingEnabled.first()
} catch (e: Exception) {
// Log to standard Android Log if our LogManager or DataStore fails early
Log.e(TAG, "Failed to retrieve isFileLoggingEnabled setting", e)
false
}
LogManager.init(applicationContext, isFileLoggingEnabled)
LogManager.i(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled")
}
}
private fun initializeDefaultData() {
applicationScope.launch(Dispatchers.IO) { // Use IO dispatcher for database operations
try {
val isFirstActualStart = userSettingsRepository.isFirstAppStart.first()
LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isFirstActualStart")
if (isFirstActualStart) {
LogManager.i(TAG, "First app start detected. Inserting default measurement types...")
databaseRepository.insertAllMeasurementTypes(getDefaultMeasurementTypes())
userSettingsRepository.setFirstAppStartCompleted(false)
LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.")
} else {
LogManager.d(TAG, "Not the first app start. Default data should already exist.")
}
} catch (e: Exception) {
LogManager.e(TAG, "Error during first-start data initialization", e)
}
}
}
override val workManagerConfiguration: Configuration by lazy {
val factory = TaskWorkerFactory(
userSettingsRepository = userSettingsRepository,
databaseRepository = databaseRepository
)
Configuration.Builder()
.setWorkerFactory(factory)
.build()
}
}

View File

@@ -17,6 +17,7 @@
*/
package com.health.openscale.core.data
import android.content.Context
import android.text.InputType
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -349,4 +350,18 @@ enum class SmoothingAlgorithm(@StringRes val displayNameResId: Int) {
fun getDisplayName(context: android.content.Context): String {
return context.getString(displayNameResId)
}
}
enum class BackupInterval {
DAILY,
WEEKLY,
MONTHLY;
fun getDisplayName(context: Context): String {
return when (this) {
DAILY -> context.getString(R.string.interval_daily)
WEEKLY -> context.getString(R.string.interval_weekly)
MONTHLY -> context.getString(R.string.interval_monthly)
}
}
}

View File

@@ -188,6 +188,10 @@ class DatabaseRepository(
// --- Measurement Type Operations ---
suspend fun insertAllMeasurementTypes(types: List<MeasurementType>) {
measurementTypeDao.insertAll(types)
}
fun getAllMeasurementTypes(): Flow<List<MeasurementType>> = measurementTypeDao.getAll()
suspend fun insertMeasurementType(type: MeasurementType): Long {

View File

@@ -30,6 +30,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.health.openscale.core.data.BackupInterval
import com.health.openscale.core.data.SmoothingAlgorithm
import com.health.openscale.core.utils.LogManager
import kotlinx.coroutines.flow.Flow
@@ -64,6 +65,13 @@ object UserPreferenceKeys {
val CHART_SMOOTHING_ALPHA = floatPreferencesKey("chart_smoothing_alpha")
val CHART_SMOOTHING_WINDOW_SIZE = intPreferencesKey("chart_smoothing_window_size")
// --- Settings for Automatic Backups ---
val AUTO_BACKUP_ENABLED_GLOBALLY = booleanPreferencesKey("auto_backup_enabled_globally")
val AUTO_BACKUP_LOCATION_URI = stringPreferencesKey("auto_backup_location_uri")
val AUTO_BACKUP_INTERVAL = stringPreferencesKey("auto_backup_interval")
val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file")
val AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP = longPreferencesKey("auto_backup_last_successful_timestamp")
// Context strings for screen-specific settings (can be used as prefixes for dynamic keys)
const val OVERVIEW_SCREEN_CONTEXT = "overview_screen"
const val GRAPH_SCREEN_CONTEXT = "graph_screen"
@@ -109,6 +117,22 @@ interface UserSettingsRepository {
val chartSmoothingWindowSize: Flow<Int>
suspend fun setChartSmoothingWindowSize(windowSize: Int)
// --- Automatic Backup Settings ---
val autoBackupEnabledGlobally: Flow<Boolean>
suspend fun setAutoBackupEnabledGlobally(enabled: Boolean)
val autoBackupLocationUri: Flow<String?>
suspend fun setAutoBackupLocationUri(uri: String?)
val autoBackupInterval: Flow<BackupInterval>
suspend fun setAutoBackupInterval(interval: BackupInterval)
val autoBackupCreateNewFile: Flow<Boolean>
suspend fun setAutoBackupCreateNewFile(createNew: Boolean)
val autoBackupLastSuccessfulTimestamp: Flow<Long>
suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long)
// Generic Settings Accessors
/**
* Observes a setting with the given key name and default value.
@@ -333,6 +357,96 @@ class UserSettingsRepositoryImpl(context: Context) : UserSettingsRepository {
saveSetting(UserPreferenceKeys.CHART_SMOOTHING_WINDOW_SIZE.name, validWindowSize)
}
override val autoBackupEnabledGlobally: Flow<Boolean> = observeSetting(
UserPreferenceKeys.AUTO_BACKUP_ENABLED_GLOBALLY.name,
false // Standardmäßig deaktiviert
).catch { exception ->
LogManager.e(TAG, "Error observing autoBackupEnabledGlobally", exception)
emit(false)
}
override suspend fun setAutoBackupEnabledGlobally(enabled: Boolean) {
LogManager.d(TAG, "Setting autoBackupEnabledGlobally to: $enabled")
saveSetting(UserPreferenceKeys.AUTO_BACKUP_ENABLED_GLOBALLY.name, enabled)
}
override val autoBackupLocationUri: Flow<String?> = dataStore.data
.catch { exception ->
LogManager.e(TAG, "Error reading autoBackupLocationUri from DataStore.", exception)
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences[UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI]
}
.distinctUntilChanged()
override suspend fun setAutoBackupLocationUri(uri: String?) {
LogManager.d(TAG, "Setting autoBackupLocationUri to: $uri")
dataStore.edit { preferences ->
if (uri != null) {
preferences[UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI] = uri
} else {
preferences.remove(UserPreferenceKeys.AUTO_BACKUP_LOCATION_URI)
}
}
}
override val autoBackupInterval: Flow<BackupInterval> = dataStore.data
.catch { exception ->
LogManager.e(TAG, "Error reading autoBackupInterval from DataStore.", exception)
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val intervalName = preferences[UserPreferenceKeys.AUTO_BACKUP_INTERVAL]
try {
intervalName?.let { BackupInterval.valueOf(it) } ?: BackupInterval.WEEKLY
} catch (e: IllegalArgumentException) {
LogManager.w(TAG, "Invalid BackupInterval name '$intervalName' in DataStore. Defaulting to WEEKLY.", e)
BackupInterval.WEEKLY
}
}
.distinctUntilChanged()
override suspend fun setAutoBackupInterval(interval: BackupInterval) {
LogManager.d(TAG, "Setting autoBackupInterval to: ${interval.name}")
saveSetting(UserPreferenceKeys.AUTO_BACKUP_INTERVAL.name, interval.name)
}
override val autoBackupCreateNewFile: Flow<Boolean> = observeSetting(
UserPreferenceKeys.AUTO_BACKUP_CREATE_NEW_FILE.name,
false
).catch { exception ->
LogManager.e(TAG, "Error observing autoBackupCreateNewFile", exception)
emit(false)
}
override suspend fun setAutoBackupCreateNewFile(createNew: Boolean) {
LogManager.d(TAG, "Setting autoBackupCreateNewFile to: $createNew")
saveSetting(UserPreferenceKeys.AUTO_BACKUP_CREATE_NEW_FILE.name, createNew)
}
override val autoBackupLastSuccessfulTimestamp: Flow<Long> = observeSetting(
UserPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name,
0L
).catch { exception ->
LogManager.e(TAG, "Error observing autoBackupLastSuccessfulTimestamp", exception)
emit(0L) // Fallback
}
override suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long) {
LogManager.d(TAG, "Setting autoBackupLastSuccessfulTimestamp to: $timestamp")
saveSetting(UserPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name, timestamp)
}
@Suppress("UNCHECKED_CAST")
override fun <T> observeSetting(keyName: String, defaultValue: T): Flow<T> {
LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'")

View File

@@ -0,0 +1,132 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.worker
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.database.UserSettingsRepository
import com.health.openscale.core.utils.LogManager
import kotlinx.coroutines.flow.first
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class BackupWorker(
appContext: Context,
workerParams: WorkerParameters,
private val userSettingsRepository: UserSettingsRepository,
private val databaseRepository: DatabaseRepository
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val TAG = "AutoBackupWorker"
const val WORK_NAME = "com.health.openscale.AUTO_DATABASE_BACKUP"
}
override suspend fun doWork(): Result {
LogManager.i(TAG, "Automatic backup worker started.")
val isEnabled = userSettingsRepository.autoBackupEnabledGlobally.first()
val locationUriString = userSettingsRepository.autoBackupLocationUri.first()
val createNewFile = userSettingsRepository.autoBackupCreateNewFile.first()
if (!isEnabled || locationUriString == null) {
LogManager.i(TAG, "Auto backup is disabled or location not set. Worker finishing.")
return Result.success()
}
val backupDirUri = Uri.parse(locationUriString)
val parentDocumentFile = DocumentFile.fromTreeUri(applicationContext, backupDirUri)
if (parentDocumentFile == null || !parentDocumentFile.canWrite()) {
LogManager.e(TAG, "Cannot write to backup location: $locationUriString. Permissions might be lost or URI invalid.")
userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(0L)
return Result.failure()
}
try {
val dbName = databaseRepository.getDatabaseName()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val baseBackupFileName = "${dbName}_auto_backup"
val finalFileName = if (createNewFile) "${baseBackupFileName}_$timeStamp.zip" else "$baseBackupFileName.zip"
var backupDocumentFile = parentDocumentFile.findFile(finalFileName)
if (backupDocumentFile != null && backupDocumentFile.exists()) {
if (createNewFile) {
LogManager.w(TAG, "File $finalFileName already exists, but createNewFile is true. Creating with new timestamp again.")
} else {
if (!backupDocumentFile.delete()) {
LogManager.e(TAG, "Could not delete existing file $finalFileName for overwrite.")
return Result.failure()
}
backupDocumentFile = null
}
}
if (backupDocumentFile == null) {
backupDocumentFile = parentDocumentFile.createFile("application/zip", finalFileName)
}
if (backupDocumentFile == null) {
LogManager.e(TAG, "Could not create backup file: $finalFileName in $locationUriString")
return Result.failure()
}
applicationContext.contentResolver.openOutputStream(backupDocumentFile.uri)?.use { outputStream ->
ZipOutputStream(outputStream).use { zipOutputStream ->
val dbFile = applicationContext.getDatabasePath(dbName)
val dbDir = dbFile.parentFile ?: return Result.failure()
val filesToBackup = listOfNotNull(
dbFile,
File(dbDir, "$dbName-shm"),
File(dbDir, "$dbName-wal")
)
filesToBackup.forEach { file ->
if (file.exists() && file.isFile) {
FileInputStream(file).use { fileInputStream ->
val entry = ZipEntry(file.name)
zipOutputStream.putNextEntry(entry)
fileInputStream.copyTo(zipOutputStream)
zipOutputStream.closeEntry()
}
}
}
}
} ?: return Result.failure()
LogManager.i(TAG, "Automatic backup successful to: ${backupDocumentFile.uri}")
userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(System.currentTimeMillis())
return Result.success()
} catch (e: Exception) {
LogManager.e(TAG, "Error during automatic backup", e)
userSettingsRepository.setAutoBackupLastSuccessfulTimestamp(0L)
return Result.failure()
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.worker
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.database.UserSettingsRepository
/**
* Custom [WorkerFactory] for creating worker instances with injected dependencies.
*
* This factory is provided to WorkManager during its initialization (via [androidx.work.Configuration.Builder.setWorkerFactory])
* to allow construction of workers that require dependencies beyond the default `Context` and `WorkerParameters`.
*/
class TaskWorkerFactory(
// Dependencies provided by the Application class, to be passed to the workers.
private val userSettingsRepository: UserSettingsRepository,
private val databaseRepository: DatabaseRepository
) : WorkerFactory() {
/**
* Called by WorkManager to create an instance of a [ListenableWorker].
*
* @param appContext The application [Context].
* @param workerClassName The fully-qualified class name of the worker to create.
* @param workerParameters The [WorkerParameters] for the worker.
* @return An instance of the [ListenableWorker], or `null` if this factory
* cannot create the requested worker type.
*/
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
return when (workerClassName) {
BackupWorker::class.java.name ->
BackupWorker(
appContext,
workerParameters,
userSettingsRepository,
databaseRepository
)
// Add other 'when' branches here if you have other custom workers
// that this factory should create.
// e.g.:
// AnotherCustomWorker::class.java.name ->
// AnotherCustomWorker(appContext, workerParameters, someOtherDependency)
else ->
null
}
}
}

View File

@@ -17,14 +17,23 @@
*/
package com.health.openscale.ui.screen.settings
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.ViewModel
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter
import com.health.openscale.R
import com.health.openscale.core.data.BackupInterval
import com.health.openscale.core.data.InputFieldType
import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementType
@@ -37,16 +46,19 @@ import com.health.openscale.core.model.MeasurementWithValues
import com.health.openscale.core.utils.CalculationUtil
import com.health.openscale.core.utils.Converters
import com.health.openscale.core.utils.LogManager
import com.health.openscale.core.worker.BackupWorker
import com.health.openscale.ui.screen.SharedViewModel
import kotlinx.coroutines.Dispatchers
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.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -66,6 +78,7 @@ import java.time.temporal.ChronoField
import java.time.temporal.TemporalQueries.localDate
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@@ -129,6 +142,22 @@ class SettingsViewModel(
private val _isLoadingEntireDatabaseDeletion = MutableStateFlow(false)
val isLoadingEntireDatabaseDeletion: StateFlow<Boolean> = _isLoadingEntireDatabaseDeletion.asStateFlow()
val autoBackupEnabledGlobally: StateFlow<Boolean> = userSettingsRepository.autoBackupEnabledGlobally
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val autoBackupLocationUri: StateFlow<String?> = userSettingsRepository.autoBackupLocationUri
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
val autoBackupInterval: StateFlow<BackupInterval> = userSettingsRepository.autoBackupInterval
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BackupInterval.WEEKLY)
val autoBackupCreateNewFile: StateFlow<Boolean> = userSettingsRepository.autoBackupCreateNewFile
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val autoBackupLastSuccessfulTimestamp: StateFlow<Long> = userSettingsRepository.autoBackupLastSuccessfulTimestamp
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
companion object {
private const val TAG = "SettingsViewModel"
const val ACTION_ID_EXPORT_USER_DATA = "export_user_data"
@@ -209,6 +238,76 @@ class SettingsViewModel(
return defaultLang
}
suspend fun setAutoBackupEnabledGlobally(context : Context, enabled: Boolean) {
LogManager.d(TAG, "Setting auto backup globally enabled to: $enabled")
userSettingsRepository.setAutoBackupEnabledGlobally(enabled)
scheduleOrCancelAutoBackupWorker(context.applicationContext)
}
suspend fun setAutoBackupLocationUri(context : Context, uriString: String?) {
LogManager.d(TAG, "Setting auto backup location URI to: $uriString")
userSettingsRepository.setAutoBackupLocationUri(uriString)
if (uriString != null && !autoBackupEnabledGlobally.value) {
setAutoBackupEnabledGlobally(context, true)
} else if (uriString == null) {
setAutoBackupEnabledGlobally(context,false)
}
}
suspend fun setAutoBackupInterval(context : Context, interval: BackupInterval) {
LogManager.d(TAG, "Setting auto backup interval to: ${interval.name}")
userSettingsRepository.setAutoBackupInterval(interval)
scheduleOrCancelAutoBackupWorker(context.applicationContext)
}
suspend fun setAutoBackupCreateNewFile(createNew: Boolean) {
LogManager.d(TAG, "Setting auto backup create new file to: $createNew")
userSettingsRepository.setAutoBackupCreateNewFile(createNew)
}
private fun scheduleOrCancelAutoBackupWorker(context: Context) {
viewModelScope.launch {
val isEnabled = userSettingsRepository.autoBackupEnabledGlobally.first()
val intervalSetting = userSettingsRepository.autoBackupInterval.first()
val locationUri = userSettingsRepository.autoBackupLocationUri.first()
val workManager = WorkManager.getInstance(context.applicationContext)
if (isEnabled && locationUri != null) {
val repeatIntervalMillis = when (intervalSetting) {
BackupInterval.DAILY -> 24 * 60 * 60 * 1000L
BackupInterval.WEEKLY -> 7 * 24 * 60 * 60 * 1000L
BackupInterval.MONTHLY -> 30 * 24 * 60 * 60 * 1000L
}
val flexMillis = repeatIntervalMillis / 8
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.setRequiresStorageNotLow(true)
.build()
val backupWorkRequest = PeriodicWorkRequestBuilder<BackupWorker>(
repeatIntervalMillis, TimeUnit.MILLISECONDS,
flexMillis, TimeUnit.MILLISECONDS
)
.setConstraints(constraints)
.addTag(BackupWorker.TAG)
.build()
workManager.enqueueUniquePeriodicWork(
BackupWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
backupWorkRequest
)
LogManager.i(TAG, "Scheduled auto backup worker with interval: $intervalSetting")
} else {
workManager.cancelUniqueWork(BackupWorker.WORK_NAME)
LogManager.i(TAG, "Cancelled auto backup worker.")
}
}
}
fun performCsvExport(userId: Int, uri: Uri, contentResolver: ContentResolver) {
viewModelScope.launch {
_isLoadingExport.value = true
@@ -865,7 +964,7 @@ class SettingsViewModel(
}
}
fun performDatabaseRestore(restoreUri: Uri, applicationContext: android.content.Context, contentResolver: ContentResolver) {
fun performDatabaseRestore(restoreUri: Uri, applicationContext: Context, contentResolver: ContentResolver) {
viewModelScope.launch {
_isLoadingRestore.value = true
LogManager.i(TAG, "Performing database restore from URI: $restoreUri")
@@ -988,7 +1087,7 @@ class SettingsViewModel(
LogManager.d(TAG, "Delete entire database confirmation cancelled.")
}
fun confirmDeleteEntireDatabase(applicationContext: android.content.Context) {
fun confirmDeleteEntireDatabase(applicationContext: Context) {
viewModelScope.launch {
_isLoadingEntireDatabaseDeletion.value = true
_showDeleteEntireDatabaseConfirmationDialog.value = false

View File

@@ -331,6 +331,44 @@
<string name="settings_delete_entire_database">Gesamte Datenbank löschen</string>
<string name="settings_danger_zone">Gefahrenzone</string>
<string name="settings_unknown_error">Unbekannter Fehler</string>
<string name="settings_auto_backup_title">Automatische Sicherungen</string>
<string name="settings_enable_auto_backups">Automatische Sicherungen aktivieren</string>
<string name="content_desc_auto_backups_toggle">Automatische Sicherungen umschalten</string>
<string name="settings_last_backup_status_label">Status der letzten Sicherung</string>
<string name="content_desc_backup_status_icon">Informationen zum Sicherungsstatus</string>
<string name="settings_backup_location_label">Sicherungsort</string>
<string name="content_desc_backup_location_icon">Einstellung Sicherungsort</string>
<string name="content_desc_open_backup_location_icon">Sicherungsort öffnen</string>
<string name="content_desc_change_backup_location_icon">Sicherungsort ändern</string>
<string name="settings_backup_interval_label">Sicherungsintervall</string>
<string name="content_desc_backup_interval_icon">Einstellung Sicherungsintervall</string>
<string name="content_desc_change_interval_icon">Sicherungsintervall ändern</string>
<string name="settings_backup_behavior_label">Sicherungsverhalten</string>
<string name="content_desc_backup_behavior_icon">Einstellung Sicherungsverhalten</string>
<string name="settings_last_backup_status_placeholder">Letzte Sicherung: %1$s</string>
<string name="settings_auto_backups_disabled">Automatische Sicherungen deaktiviert</string>
<string name="settings_backup_behavior_new_file">Immer neue Sicherungsdatei erstellen</string>
<string name="settings_backup_behavior_overwrite">Vorhandene Sicherungsdatei überschreiben</string>
<string name="settings_backup_location_default">Standard: App-spezifischer Ordner</string>
<string name="settings_backup_location_simulated_user_choice">Simuliert: Benutzer hat einen Ordner ausgewählt</string>
<string name="dialog_title_select_backup_interval">Sicherungsintervall auswählen</string>
<string name="settings_backup_location_not_configured">Sicherungsort nicht konfiguriert</string>
<string name="settings_backup_location_not_configured_for_auto">Ort nicht konfiguriert. Automatische Sicherungen pausiert.</string>
<string name="settings_backup_location_error_accessing">Fehler beim Zugriff auf Sicherungsort</string>
<string name="settings_backup_database_manual">Datenbank sichern (Manuell)</string>
<string name="content_desc_delete_icon">Löschen-Symbol</string>
<string name="dialog_title_select_backup_directory">Sicherungsverzeichnis auswählen</string>
<string name="settings_backup_location_select_action">Sicherungsort auswählen</string>
<string name="settings_backup_location_selected_folder">Ausgewählter Ordner</string>
<string name="settings_backup_location_open_error_no_app">Keine Anwendung zum Öffnen des Ordners gefunden.</string>
<string name="settings_backup_location_open_error">Sicherungsort konnte nicht geöffnet werden.</string>
<string name="settings_backup_location_selected_toast">Sicherungsort festgelegt auf: %1$s</string>
<string name="settings_backup_location_selection_cancelled">Ordnerauswahl abgebrochen. Automatisches Backup nicht aktiviert.</string>
<string name="settings_last_backup_status_successful">Letztes Backup: %1$s</string>
<string name="settings_last_backup_status_never">Letztes Backup: Nie</string>
<string name="interval_daily">Täglich</string>
<string name="interval_weekly">Wöchentlich</string>
<string name="interval_monthly">Monatlich</string>
<!-- Datenverwaltung Dialogtitel -->
<string name="dialog_title_export_select_user">Export: Benutzer auswählen</string>

View File

@@ -333,6 +333,44 @@
<string name="settings_delete_entire_database">Delete entire database</string>
<string name="settings_danger_zone">Danger Zone</string>
<string name="settings_unknown_error">Unknown error</string>
<string name="settings_auto_backup_title">Automatic Backups</string>
<string name="settings_enable_auto_backups">Enable automatic backups</string>
<string name="content_desc_auto_backups_toggle">Toggle automatic backups</string>
<string name="settings_last_backup_status_label">Last backup status</string>
<string name="content_desc_backup_status_icon">Backup status information</string>
<string name="settings_backup_location_label">Backup location</string>
<string name="content_desc_backup_location_icon">Backup location setting</string>
<string name="content_desc_open_backup_location_icon">Open backup location</string>
<string name="content_desc_change_backup_location_icon">Change backup location</string>
<string name="settings_backup_interval_label">Backup interval</string>
<string name="content_desc_backup_interval_icon">Backup interval setting</string>
<string name="content_desc_change_interval_icon">Change backup interval</string>
<string name="settings_backup_behavior_label">Backup Behavior</string>
<string name="content_desc_backup_behavior_icon">Backup behavior setting</string>
<string name="settings_last_backup_status_placeholder">Last backup: %1$s</string>
<string name="settings_auto_backups_disabled">Automatic backups disabled</string>
<string name="settings_backup_behavior_new_file">Always create a new backup file</string>
<string name="settings_backup_behavior_overwrite">Overwrite existing backup file</string>
<string name="settings_backup_location_default">Default: App-specific folder</string>
<string name="settings_backup_location_simulated_user_choice">Simulated: User chose a folder</string>
<string name="dialog_title_select_backup_interval">Select Backup Interval</string>
<string name="settings_backup_location_not_configured">Backup location not configured</string>
<string name="settings_backup_location_not_configured_for_auto">Location not configured. Automatic backups paused.</string>
<string name="settings_backup_location_error_accessing">Error accessing backup location</string>
<string name="settings_backup_database_manual">Backup database (Manual)</string>
<string name="content_desc_delete_icon">Delete icon</string>
<string name="dialog_title_select_backup_directory">Select Backup Directory</string>
<string name="settings_backup_location_select_action">Select Backup Location</string>
<string name="settings_backup_location_selected_folder">Selected folder</string>
<string name="settings_backup_location_open_error_no_app">No application found to open the folder.</string>
<string name="settings_backup_location_open_error">Could not open backup location.</string>
<string name="settings_backup_location_selected_toast">Backup location set to: %1$s</string>
<string name="settings_backup_location_selection_cancelled">Folder selection cancelled. Automatic backup not enabled.</string>
<string name="settings_last_backup_status_successful">Last backup: %1$s</string>
<string name="settings_last_backup_status_never">Last backup: Never</string>
<string name="interval_daily">Daily</string>
<string name="interval_weekly">Weekly</string>
<string name="interval_monthly">Monthly</string>
<!-- Data Management Dialog Titles -->
<string name="dialog_title_export_select_user">Export: Select User</string>

View File

@@ -1,17 +1,19 @@
[versions]
agp = "8.11.0"
kotlin = "2.2.0"
coreKtx = "1.16.0"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.9.1"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2025.06.01"
composeBom = "2025.08.00"
room = "2.7.2"
navigation = "2.9.1"
lifecycle = "2.9.1"
navigation = "2.9.3"
lifecycle = "2.9.2"
datastore = "1.1.7"
worker="2.10.3"
documentfile = "1.1.0"
composeCharts = "2.1.3"
composeReorderable = "2.5.1"
compose-material = "1.7.8"
@@ -41,6 +43,8 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version = "lifecycle" }
androidx-worker = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "worker" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
compose-charts = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "composeCharts" }
compose-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "composeCharts" }
compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" }