From 087c7d75d5837215a76ba33c647c05c9b4449664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Tue, 2 Jun 2026 23:19:49 +0100 Subject: [PATCH] Debug: Add device ID, allow table import from CSV --- .../debug/DatabaseTableDebugFragment.kt | 191 +++++++++++++++++- .../activities/debug/DeviceDebugFragment.kt | 8 +- app/src/main/res/values/strings.xml | 1 + .../xml/debug_preferences_database_table.xml | 6 + .../main/res/xml/debug_preferences_device.xml | 6 + 5 files changed, 209 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DatabaseTableDebugFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DatabaseTableDebugFragment.kt index 9832fabbe1..ef5410231e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DatabaseTableDebugFragment.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DatabaseTableDebugFragment.kt @@ -3,8 +3,10 @@ package nodomain.freeyourgadget.gadgetbridge.activities.debug import android.app.Activity import android.content.ClipData import android.content.ClipboardManager +import android.content.ContentValues import android.content.Context import android.content.Intent +import android.database.sqlite.SQLiteDatabase import android.net.Uri import android.os.Bundle import android.widget.TextView @@ -38,6 +40,7 @@ import java.util.Locale @Suppress("unused") class DatabaseTableDebugFragment : AbstractDebugFragment() { private var pendingExportData: Triple? = null + private var pendingImportTableName: String? = null private val saveFileLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -53,6 +56,16 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { pendingExportData = null } + private val openFileLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val tableName = pendingImportTableName + pendingImportTableName = null + if (uri != null && tableName != null) { + importTableFromUri(tableName, uri) + } + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.debug_preferences_database_table, rootKey) @@ -64,6 +77,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { onClick(PREF_DEBUG_EXPORT_TABLE) { startTableExport(tableName) } + onClick(PREF_DEBUG_IMPORT_TABLE) { startTableImport(tableName) } + onClick(PREF_DEBUG_CLEAR_TABLE) { clearTable(tableName) } if (BuildConfig.DEBUG) { @@ -107,7 +122,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { cursor.use { it.moveToNext() - findPreference(PREF_DEBUG_DATABASE_COUNT)?.summary = it.getInt(it.getColumnIndexOrThrow("count")).toString() + findPreference(PREF_DEBUG_DATABASE_COUNT)?.summary = + it.getInt(it.getColumnIndexOrThrow("count")).toString() } } } catch (e: Exception) { @@ -400,7 +416,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { val value = when (it.getType(index)) { android.database.Cursor.FIELD_TYPE_BLOB -> " "%02x".format(b) }}>" - else -> it.getString(index) ?: "" + + else -> it.getString(index) ?: NULL_FIELD_CSV_VALUE } escapeValueForCsv(value) } else { @@ -443,6 +460,173 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { } } + private fun startTableImport(tableName: String) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Import into $tableName") + .setMessage("Import a CSV file into $tableName? Existing rows with the same primary key will be replaced.") + .setPositiveButton(R.string.file_import) { _, _ -> + pendingImportTableName = tableName + openFileLauncher.launch(arrayOf("text/csv", "text/plain", "*/*")) + } + .setNegativeButton(R.string.Cancel, null) + .show() + } + + private fun importTableFromUri(tableName: String, uri: Uri) { + try { + val inputStream = requireContext().contentResolver.openInputStream(uri) + ?: throw Exception("Failed to open file") + + val lines = inputStream.use { stream -> + stream.bufferedReader(StandardCharsets.UTF_8).readLines() + } + + if (lines.isEmpty()) { + GB.toast("File is empty", Toast.LENGTH_LONG, GB.WARN) + return + } + + val headers = parseCsvLine(lines[0]) + if (headers.isEmpty()) { + GB.toast("File has no headers", Toast.LENGTH_LONG, GB.ERROR) + return + } + + // Validate that all headers exist as columns in the table + val tableColumns = getTableColumns(tableName) + val unknownHeaders = headers.filter { it !in tableColumns } + if (!unknownHeaders.isEmpty()) { + GB.toast("File contains unknown columns", Toast.LENGTH_LONG, GB.ERROR) + return + } + + var rowsImported = 0 + var rowsFailed = 0 + + GBApplication.acquireDB().use { db -> + db.database.beginTransaction() + try { + linesLoop@ for (i in 1 until lines.size) { + val line = lines[i] + if (line.isBlank()) continue + + val values = parseCsvLine(line) + if (values.size != headers.size) { + rowsFailed++ + continue + } + + val contentValues = ContentValues() + for (j in headers.indices) { + if (headers[j] in tableColumns) { + val value = values[j] + when { + value == NULL_FIELD_CSV_VALUE -> contentValues.putNull(headers[j]) + value.startsWith("") -> { + val blob = parseBlobHex(value) + if (blob == null) { + rowsFailed++ + continue@linesLoop + } + contentValues.put(headers[j], blob) + } + + else -> contentValues.put(headers[j], value) + } + } + } + + val result = db.database.insertWithOnConflict( + tableName, + null, + contentValues, + SQLiteDatabase.CONFLICT_REPLACE + ) + if (result == -1L) { + rowsFailed++ + } else { + rowsImported++ + } + } + db.database.setTransactionSuccessful() + } finally { + db.database.endTransaction() + } + } + + loadTableCount(tableName) + + val message = if (rowsFailed == 0) { + "Imported $rowsImported rows" + } else { + "Imported $rowsImported rows, $rowsFailed failed" + } + GB.toast(message, Toast.LENGTH_LONG, GB.INFO) + } catch (e: Exception) { + LOG.error("Failed to import table", e) + GB.toast("Import failed: ${e.localizedMessage}", Toast.LENGTH_LONG, GB.ERROR) + } + } + + private fun getTableColumns(tableName: String): Set { + GBApplication.acquireDB().use { db -> + val cursor = db.database.rawQuery("PRAGMA table_info($tableName)", null) + val columns = mutableSetOf() + cursor.use { + while (it.moveToNext()) { + columns.add(it.getString(it.getColumnIndexOrThrow("name"))) + } + } + return columns + } + } + + private fun parseBlobHex(value: String): ByteArray? { + val hex = value.removePrefix("") + if (hex.length % 2 != 0) return null + return try { + ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } + } catch (e: NumberFormatException) { + LOG.error("Failed to parse blob from {}", value, e) + null + } + } + + private fun parseCsvLine(line: String): List { + val fields = mutableListOf() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + + while (i < line.length) { + val c = line[i] + when { + inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> { + sb.append('"') + i += 2 + } + + c == '"' -> { + inQuotes = !inQuotes + i++ + } + + c == ',' && !inQuotes -> { + fields.add(sb.toString()) + sb.clear() + i++ + } + + else -> { + sb.append(c) + i++ + } + } + } + fields.add(sb.toString()) + return fields + } + private fun clearTable(tableName: String) { MaterialAlertDialogBuilder(requireContext()) .setCancelable(true) @@ -488,8 +672,11 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() { companion object { private val LOG = LoggerFactory.getLogger(DatabaseTableDebugFragment::class.java) + private const val NULL_FIELD_CSV_VALUE = "" + private const val PREF_DEBUG_DATABASE_COUNT = "pref_debug_database_count" private const val PREF_DEBUG_EXPORT_TABLE = "pref_debug_export_table" + private const val PREF_DEBUG_IMPORT_TABLE = "pref_debug_import_table" private const val PREF_HEADER_DANGEROUS_ACTIONS = "pref_header_dangerous_actions" private const val PREF_DEBUG_CLEAR_TABLE = "pref_debug_clear_table" private const val PREF_DEBUG_DROP_TABLE = "pref_debug_drop_table" diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DeviceDebugFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DeviceDebugFragment.kt index 51d5a75689..968f424b77 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DeviceDebugFragment.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/debug/DeviceDebugFragment.kt @@ -30,11 +30,15 @@ class DeviceDebugFragment : AbstractDebugFragment() { gbDevice = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { arguments?.getParcelable(GBDevice.EXTRA_DEVICE, GBDevice::class.java)!! } else { - arguments?.getParcelable(GBDevice.EXTRA_DEVICE)!! + arguments?.getParcelable(GBDevice.EXTRA_DEVICE)!! } preferenceScreen?.title = gbDevice.aliasOrName + findPreference(PREF_DEBUG_DEVICE_ID)?.summary = GBApplication.acquireDbReadOnly().use { dbHandler -> + DBHelper.findDevice(gbDevice, dbHandler.daoSession)?.id?.toString() ?: "" + } + findPreference(PREF_DEBUG_DEVICE_NAME)?.summary = gbDevice.name ?: "" if (gbDevice.alias.isNullOrEmpty()) { findPreference(PREF_DEBUG_DEVICE_ALIAS)?.summary = getString(R.string.not_set) @@ -130,6 +134,7 @@ class DeviceDebugFragment : AbstractDebugFragment() { @Suppress("UNCHECKED_CAST") editorNew.putStringSet(key, value as Set) } + else -> { LOG.error("Unexpected preference type {}", value?.javaClass) GB.toast("Failed to copy settings", Toast.LENGTH_LONG, GB.ERROR) @@ -204,6 +209,7 @@ class DeviceDebugFragment : AbstractDebugFragment() { companion object { private val LOG: Logger = LoggerFactory.getLogger(DeviceDebugFragment::class.java) + private const val PREF_DEBUG_DEVICE_ID = "pref_debug_device_id" private const val PREF_DEBUG_DEVICE_NAME = "pref_debug_device_name" private const val PREF_DEBUG_DEVICE_ALIAS = "pref_debug_device_alias" private const val PREF_DEBUG_DEVICE_MAC_ADDRESS = "pref_debug_device_mac_address" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c608a1105..243bcf316b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1239,6 +1239,7 @@ Style follows Watchface Style Keep the band\'s display always on + Device ID Device name Device alias Password diff --git a/app/src/main/res/xml/debug_preferences_database_table.xml b/app/src/main/res/xml/debug_preferences_database_table.xml index 81234bed37..5ecac1b4d1 100644 --- a/app/src/main/res/xml/debug_preferences_database_table.xml +++ b/app/src/main/res/xml/debug_preferences_database_table.xml @@ -16,6 +16,12 @@ android:persistent="false" android:title="@string/file_export" /> + + diff --git a/app/src/main/res/xml/debug_preferences_device.xml b/app/src/main/res/xml/debug_preferences_device.xml index 096c4cfc61..6dba67f6ab 100644 --- a/app/src/main/res/xml/debug_preferences_device.xml +++ b/app/src/main/res/xml/debug_preferences_device.xml @@ -8,6 +8,12 @@ android:title="@string/prefs_device_name" app:iconSpaceReserved="false" /> + +