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

Implement database migration from version 6 to 7

This commit is contained in:
oliexdev
2025-08-19 20:50:13 +02:00
parent f29bd962d2
commit 8c7a62c401
9 changed files with 316 additions and 74 deletions

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "3eb1fa6c355e18713262b7da7d1ffbdd",
"identityHash": "6ae361e9bd8dbaaa952bdfdb45272187",
"entities": [
{
"tableName": "User",
@@ -281,12 +281,23 @@
"columnNames": [
"id"
]
}
},
"indices": [
{
"name": "index_MeasurementType_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_MeasurementType_key` ON `${TABLE_NAME}` (`key`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3eb1fa6c355e18713262b7da7d1ffbdd')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6ae361e9bd8dbaaa952bdfdb45272187')"
]
}
}

View File

@@ -20,9 +20,12 @@ package com.health.openscale.core.data
import android.content.Context
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity
@Entity(
indices = [Index(value = ["key"], unique = true)]
)
data class MeasurementType(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val key: MeasurementTypeKey = MeasurementTypeKey.CUSTOM,

View File

@@ -22,6 +22,8 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementValue
@@ -106,8 +108,206 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
DATABASE_NAME
)
// TODO Add any other configurations like .addCallback(), .setQueryExecutor(), etc. here if needed.
.addMigrations(MIGRATION_6_7)
.build()
}
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("PRAGMA foreign_keys=OFF")
// --- Create tables ---
db.execSQL("""
CREATE TABLE IF NOT EXISTS `User`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` TEXT NOT NULL,
`birthDate` INTEGER NOT NULL,
`gender` TEXT NOT NULL,
`heightCm` REAL NOT NULL,
`activityLevel` TEXT NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS `Measurement`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`userId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Measurement_userId` ON `Measurement` (`userId`)")
db.execSQL("""
CREATE TABLE IF NOT EXISTS `MeasurementType`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`key` TEXT NOT NULL,
`name` TEXT,
`color` INTEGER NOT NULL,
`icon` TEXT NOT NULL,
`unit` TEXT NOT NULL,
`inputType` TEXT NOT NULL,
`displayOrder` INTEGER NOT NULL,
`isDerived` INTEGER NOT NULL,
`isEnabled` INTEGER NOT NULL,
`isPinned` INTEGER NOT NULL,
`isOnRightYAxis` INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE UNIQUE INDEX IF NOT EXISTS `index_MeasurementType_key`
ON `MeasurementType`(`key`)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS `MeasurementValue`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`measurementId` INTEGER NOT NULL,
`typeId` INTEGER NOT NULL,
`floatValue` REAL,
`intValue` INTEGER,
`textValue` TEXT,
`dateValue` INTEGER,
FOREIGN KEY(`measurementId`) REFERENCES `Measurement`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY(`typeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `MeasurementValue` (`measurementId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `MeasurementValue` (`typeId`)")
// --- Create measurement type idempotent (INSERT OR IGNORE, used UNIQUE-Index) ---
fun ensureType(
key: String,
unit: String,
color: Int,
icon: String,
inputType: String = "FLOAT",
displayOrder: Int,
isDerived: Int = 0,
isEnabled: Int = 1,
isPinned: Int = 0,
isOnRightYAxis: Int = 0
) {
db.execSQL(
"""
INSERT OR IGNORE INTO MeasurementType
(`key`,`name`,`color`,`icon`,`unit`,`inputType`,`displayOrder`,
`isDerived`,`isEnabled`,`isPinned`,`isOnRightYAxis`)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""".trimIndent(),
arrayOf(key, null, color, icon, unit, inputType, displayOrder, isDerived, isEnabled, isPinned, isOnRightYAxis)
)
}
var order = 1
ensureType("WEIGHT", "KG", 0xFF7E57C2.toInt(), "IC_WEIGHT", displayOrder = order++, isPinned = 1, isOnRightYAxis = 1)
ensureType("BMI", "NONE", 0xFFFFCA28.toInt(), "IC_BMI", displayOrder = order++, isDerived = 1, isPinned = 1)
ensureType("BODY_FAT", "PERCENT", 0xFFEF5350.toInt(), "IC_BODY_FAT", displayOrder = order++, isPinned = 1)
ensureType("WATER", "PERCENT", 0xFF29B6F6.toInt(), "IC_WATER", displayOrder = order++, isPinned = 1)
ensureType("MUSCLE", "PERCENT", 0xFF66BB6A.toInt(), "IC_MUSCLE", displayOrder = order++, isPinned = 1)
ensureType("LBM", "KG", 0xFF4DBAC0.toInt(), "IC_LBM", displayOrder = order++)
ensureType("BONE", "KG", 0xFFBDBDBD.toInt(), "IC_BONE", displayOrder = order++)
ensureType("WAIST", "CM", 0xFF78909C.toInt(), "IC_WAIST", displayOrder = order++)
ensureType("WHR", "NONE", 0xFFFFA726.toInt(), "IC_WHR", displayOrder = order++, isDerived = 1)
ensureType("WHTR", "NONE", 0xFFFF7043.toInt(), "IC_WHTR", displayOrder = order++, isDerived = 1)
ensureType("HIPS", "CM", 0xFF5C6BC0.toInt(), "IC_HIPS", displayOrder = order++)
ensureType("VISCERAL_FAT","NONE", 0xFFD84315.toInt(), "IC_VISCERAL_FAT", displayOrder = order++)
ensureType("CHEST", "CM", 0xFF8E24AA.toInt(), "IC_CHEST", displayOrder = order++)
ensureType("THIGH", "CM", 0xFFA1887F.toInt(), "IC_THIGH", displayOrder = order++)
ensureType("BICEPS", "CM", 0xFFEC407A.toInt(), "IC_BICEPS", displayOrder = order++)
ensureType("NECK", "CM", 0xFFB0BEC5.toInt(), "IC_NECK", displayOrder = order++)
ensureType("CALIPER_1","CM", 0xFFFFF59D.toInt(), "IC_CALIPER1", displayOrder = order++)
ensureType("CALIPER_2","CM", 0xFFFFE082.toInt(), "IC_CALIPER2", displayOrder = order++)
ensureType("CALIPER_3","CM", 0xFFFFCC80.toInt(), "IC_CALIPER3", displayOrder = order++)
ensureType("CALIPER", "PERCENT", 0xFFFB8C00.toInt(), "IC_FAT_CALIPER", displayOrder = order++, isDerived = 1)
ensureType("BMR", "KCAL", 0xFFAB47BC.toInt(), "IC_BMR", displayOrder = order++, isDerived = 1)
ensureType("TDEE", "KCAL", 0xFF26A69A.toInt(), "IC_TDEE", displayOrder = order++, isDerived = 1)
ensureType("CALORIES", "KCAL", 0xFF4CAF50.toInt(), "IC_CALORIES", displayOrder = order++)
ensureType("COMMENT", "NONE", 0xFFE0E0E0.toInt(), "IC_COMMENT", inputType = "TEXT", displayOrder = order++, isPinned = 1)
ensureType("DATE", "NONE", 0xFF9E9E9E.toInt(), "IC_DATE", inputType = "DATE", displayOrder = order++)
ensureType("TIME", "NONE", 0xFF757575.toInt(), "IC_TIME", inputType = "TIME", displayOrder = order++)
// --- Migrate users ---
db.execSQL("""
INSERT INTO `User` (id, name, birthDate, gender, heightCm, activityLevel)
SELECT
u.id,
u.username,
u.birthday,
CASE u.gender WHEN 0 THEN 'MALE' ELSE 'FEMALE' END,
u.bodyHeight,
CASE u.activityLevel
WHEN 0 THEN 'SEDENTARY'
WHEN 1 THEN 'MILD'
WHEN 2 THEN 'MODERATE'
WHEN 3 THEN 'HEAVY'
WHEN 4 THEN 'EXTREME'
ELSE 'SEDENTARY' END
FROM `scaleUsers` u
""".trimIndent())
// --- Migrate measurements (only enabled = 1) ---
db.execSQL("""
INSERT INTO `Measurement` (id, userId, timestamp)
SELECT m.id, m.userId, COALESCE(m.datetime, 0)
FROM `scaleMeasurements` m
WHERE m.enabled = 1
""".trimIndent())
// --- Migrate values ---
fun insertFloat(column: String, key: String) {
db.execSQL("""
INSERT INTO MeasurementValue (measurementId, typeId, floatValue)
SELECT m.id,
(SELECT id FROM MeasurementType WHERE `key` = ?),
m.`$column`
FROM scaleMeasurements m
WHERE m.enabled = 1
AND m.`$column` IS NOT NULL
AND m.`$column` != 0
""".trimIndent(), arrayOf(key))
}
fun insertText(column: String, key: String) {
db.execSQL("""
INSERT INTO MeasurementValue (measurementId, typeId, textValue)
SELECT m.id,
(SELECT id FROM MeasurementType WHERE `key` = ?),
m.`$column`
FROM scaleMeasurements m
WHERE m.enabled = 1
AND m.`$column` IS NOT NULL
AND m.`$column` != ''
""".trimIndent(), arrayOf(key))
}
insertFloat("weight", "WEIGHT")
insertFloat("fat", "BODY_FAT")
insertFloat("water", "WATER")
insertFloat("muscle", "MUSCLE")
insertFloat("visceralFat", "VISCERAL_FAT")
insertFloat("lbm", "LBM")
insertFloat("waist", "WAIST")
insertFloat("hip", "HIPS")
insertFloat("bone", "BONE")
insertFloat("chest", "CHEST")
insertFloat("thigh", "THIGH")
insertFloat("biceps", "BICEPS")
insertFloat("neck", "NECK")
insertFloat("caliper1", "CALIPER_1")
insertFloat("caliper2", "CALIPER_2")
insertFloat("caliper3", "CALIPER_3")
insertFloat("calories", "CALORIES")
insertText ("comment", "COMMENT")
// --- Cleanup ---
db.execSQL("DROP INDEX IF EXISTS `index_scaleMeasurements_userId_datetime`")
db.execSQL("DROP TABLE IF EXISTS `scaleMeasurements`")
db.execSQL("DROP TABLE IF EXISTS `scaleUsers`")
db.execSQL("PRAGMA foreign_keys=ON")
}
}

View File

@@ -31,7 +31,7 @@ interface MeasurementTypeDao {
@Insert
suspend fun insert(type: MeasurementType): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(types: List<MeasurementType>)
@Update

View File

@@ -768,12 +768,28 @@ class SharedViewModel(
userSettingRepository.setCurrentUserId(null)
}
} else {
LogManager.i(TAG, "Init: No user ID found in settings. No user auto-selected. (Initialization Logic)")
LogManager.i(TAG, "Init: No user ID found in settings. Attempting auto-selection. (Initialization Logic)")
// --- Auto-selection logic if no user ID was saved ---
val users = databaseRepository.getAllUsers().first()
if (users.isNotEmpty()) {
// Case A: user exists -> select automatically
val only = users.first()
_selectedUserId.value = only.id
userSettingRepository.setCurrentUserId(only.id)
LogManager.i(TAG, "Init: Auto-selected only user ${only.id}. (Initialization Result)")
} else {
// Case B: No users at all -> leave selection empty
LogManager.i(TAG, "Init: No users found in DB. Leaving selection null. (Initialization Logic)")
}
}
}
LogManager.i(TAG, "ViewModel initialization complete. (Lifecycle Event)")
}
private fun triggerSyncInsertMeasurement(
measurementToSave: Measurement,
valuesToSave: List<MeasurementValue>,

View File

@@ -319,7 +319,7 @@ fun DataManagementSettingsScreen(
buildList {
add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_export_measurements_csv), Icons.Default.FileDownload, { if (!isAnyOperationLoading) settingsViewModel.startExportProcess() }, users.isNotEmpty() && !isAnyOperationLoading, isLoading = isLoadingExport))
add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_import_measurements_csv), Icons.Default.FileUpload, { if (!isAnyOperationLoading) settingsViewModel.startImportProcess() }, users.isNotEmpty() && !isAnyOperationLoading, isLoading = isLoadingImport))
add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_backup_database_manual), Icons.Default.CloudDownload, { if (!isAnyOperationLoading) settingsViewModel.startDatabaseBackup() }, !isAnyOperationLoading, isLoading = isLoadingBackup))
add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_backup_database), Icons.Default.CloudDownload, { if (!isAnyOperationLoading) settingsViewModel.startDatabaseBackup() }, !isAnyOperationLoading, isLoading = isLoadingBackup))
add(DataManagementSettingListItem.ActionItem(context.getString(R.string.settings_restore_database), Icons.Filled.CloudUpload, { if (!isAnyOperationLoading) showRestoreConfirmationDialog = true }, !isAnyOperationLoading, isLoading = isLoadingRestore))
}
}

View File

@@ -978,75 +978,106 @@ class SettingsViewModel(
return@launch
}
// Close the database before attempting to overwrite files
LogManager.d(TAG, "Attempting to close database before restore.")
repository.closeDatabase() // Ensure this method exists and correctly closes Room
repository.closeDatabase()
LogManager.i(TAG, "Database closed for restore operation.")
withContext(Dispatchers.IO) {
// Detect format by checking ZIP magic header
val isZip = contentResolver.openInputStream(restoreUri)?.use { ins ->
val header = ByteArray(4)
val read = ins.read(header)
read == 4 &&
header[0].toInt() == 0x50 &&
header[1].toInt() == 0x4B &&
header[2].toInt() == 0x03 &&
header[3].toInt() == 0x04
} ?: false
var restoreSuccessful = false
var mainDbRestored = false
fun deleteIfExists(file: File) {
if (file.exists() && !file.delete()) {
LogManager.w(TAG, "Could not delete ${file.absolutePath} before restore.")
}
}
val shm = File(dbDir, "$dbName-shm")
val wal = File(dbDir, "$dbName-wal")
try {
contentResolver.openInputStream(restoreUri)?.use { inputStream ->
ZipInputStream(inputStream).use { zipInputStream ->
var entry: ZipEntry? = zipInputStream.nextEntry
while (entry != null) {
val outputFile = File(dbDir, entry.name)
// Basic path traversal protection
if (!outputFile.canonicalPath.startsWith(dbDir.canonicalPath)) {
LogManager.e(TAG, "Skipping restore of entry '${entry.name}' due to path traversal attempt.")
entry = zipInputStream.nextEntry
continue
}
if (isZip) {
// --- New format: ZIP archive ---
LogManager.i(TAG, "Detected ZIP backup format.")
contentResolver.openInputStream(restoreUri)?.use { inputStream ->
ZipInputStream(inputStream).use { zipInputStream ->
deleteIfExists(dbFile)
deleteIfExists(shm)
deleteIfExists(wal)
// Delete existing file before restoring (important for WAL mode)
if (outputFile.exists()) {
if (!outputFile.delete()) {
LogManager.w(TAG, "Could not delete existing file ${outputFile.name} before restore. Restore might fail or be incomplete.")
var entry: ZipEntry? = zipInputStream.nextEntry
var mainDbRestored = false
while (entry != null) {
val out = File(dbDir, entry.name)
if (!out.canonicalPath.startsWith(dbDir.canonicalPath)) {
LogManager.e(TAG, "Skipping '${entry.name}' due to path traversal.")
entry = zipInputStream.nextEntry
continue
}
deleteIfExists(out)
FileOutputStream(out).use { zipInputStream.copyTo(it) }
LogManager.d(TAG, "Restored ${entry.name} to ${out.absolutePath}")
if (entry.name == dbName) mainDbRestored = true
entry = zipInputStream.nextEntry
}
FileOutputStream(outputFile).use { fileOutputStream ->
zipInputStream.copyTo(fileOutputStream)
if (!mainDbRestored) {
LogManager.e(TAG, "Main DB file '$dbName' missing in ZIP.")
sharedViewModel.showSnackbar(R.string.restore_error_db_files_missing)
return@withContext
}
LogManager.d(TAG, "Restored ${entry.name} from backup archive to ${outputFile.absolutePath}.")
if (entry.name == dbName) {
mainDbRestored = true
}
entry = zipInputStream.nextEntry
}
} ?: run {
sharedViewModel.showSnackbar(R.string.restore_error_no_input_stream)
LogManager.e(TAG, "Restore failed: Could not open InputStream for Uri: $restoreUri")
return@withContext
}
} ?: run {
sharedViewModel.showSnackbar(R.string.restore_error_no_input_stream)
LogManager.e(TAG, "Restore failed: Could not open InputStream for Uri: $restoreUri")
return@withContext
}
restoreSuccessful = true
} else {
// --- Legacy format: single .db file ---
LogManager.i(TAG, "Detected legacy backup format (.db).")
contentResolver.openInputStream(restoreUri)?.use { inputStream ->
deleteIfExists(dbFile)
deleteIfExists(shm)
deleteIfExists(wal)
if (!mainDbRestored) {
LogManager.e(TAG, "Restore failed: Main database file '$dbName' not found in the backup archive.")
sharedViewModel.showSnackbar(R.string.restore_error_db_files_missing)
// Attempt to clean up partially restored files might be needed here, or let the user handle it.
return@withContext
val tmp = File(dbDir, "$dbName.tmp-restore")
FileOutputStream(tmp).use { output -> inputStream.copyTo(output) }
if (!tmp.renameTo(dbFile)) {
LogManager.w(TAG, "Rename temp restore file failed, using copy as final.")
}
LogManager.d(TAG, "Restored legacy DB file to ${dbFile.absolutePath}")
} ?: run {
sharedViewModel.showSnackbar(R.string.restore_error_no_input_stream)
LogManager.e(TAG, "Restore failed: Could not open InputStream for Uri: $restoreUri")
return@withContext
}
restoreSuccessful = true
}
restoreSuccessful = true
} catch (e: IOException) {
LogManager.e(TAG, "IO Error during database restore from URI $restoreUri", e)
val errorMsg = e.localizedMessage ?: "Unknown I/O error"
sharedViewModel.showSnackbar(R.string.restore_error_generic, listOf(errorMsg))
LogManager.e(TAG, "IO Error during restore from URI $restoreUri", e)
val msg = e.localizedMessage ?: "Unknown I/O error"
sharedViewModel.showSnackbar(R.string.restore_error_generic, listOf(msg))
return@withContext
} catch (e: IllegalStateException) { // Can be thrown by ZipInputStream
LogManager.e(TAG, "Error processing ZIP file during restore from URI $restoreUri", e)
} catch (e: IllegalStateException) {
// ZIP stream error (not a valid archive)
LogManager.e(TAG, "ZIP processing error during restore.", e)
sharedViewModel.showSnackbar(R.string.restore_error_zip_format)
return@withContext
}
if (restoreSuccessful) {
LogManager.i(TAG, "Database restore from $restoreUri successful. App restart is required.")
LogManager.i(TAG, "Database restore successful. App restart recommended.")
sharedViewModel.showSnackbar(R.string.restore_successful)
// The app needs to be restarted for Room to pick up the new database files correctly.
// This usually involves sharedViewModel.requestAppRestart() or similar mechanism.
}
}
} catch (e: Exception) {
@@ -1054,23 +1085,6 @@ class SettingsViewModel(
val errorMsg = e.localizedMessage ?: "Unknown error"
sharedViewModel.showSnackbar(R.string.restore_error_generic, listOf(errorMsg))
} finally {
// Re-open the database regardless of success, unless app is restarting
// If an app restart is requested, reopening might not be necessary or could cause issues.
// However, if the restore failed and no restart is pending, the DB should be reopened.
if (!_isLoadingRestore.value) { // Check if not already restarting
try {
LogManager.d(TAG, "Attempting to re-open database after restore attempt.")
// This might require re-initialization of the Room database instance
// if the underlying files were changed.
// For simplicity, we assume the repository handles this.
// A full app restart is generally the safest way after a DB restore.
// TODO repository.reopenDatabase() // Ensure this method exists and correctly re-opens Room
LogManager.i(TAG, "Database re-opened after restore attempt.")
} catch (reopenError: Exception) {
LogManager.e(TAG, "Error re-opening database after restore attempt. App restart is highly recommended.", reopenError)
sharedViewModel.showSnackbar(R.string.restore_error_generic, listOf("Error re-opening database."))
}
}
_isLoadingRestore.value = false
LogManager.i(TAG, "Database restore process finished for URI: $restoreUri.")
}

View File

@@ -355,7 +355,6 @@
<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>

View File

@@ -357,7 +357,6 @@
<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>