Debug: Add device ID, allow table import from CSV

This commit is contained in:
José Rebelo
2026-06-02 23:19:49 +01:00
parent a7f512356c
commit 087c7d75d5
5 changed files with 209 additions and 3 deletions
@@ -3,8 +3,10 @@ package nodomain.freeyourgadget.gadgetbridge.activities.debug
import android.app.Activity import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.sqlite.SQLiteDatabase
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.TextView import android.widget.TextView
@@ -38,6 +40,7 @@ import java.util.Locale
@Suppress("unused") @Suppress("unused")
class DatabaseTableDebugFragment : AbstractDebugFragment() { class DatabaseTableDebugFragment : AbstractDebugFragment() {
private var pendingExportData: Triple<String, Date, Date>? = null private var pendingExportData: Triple<String, Date, Date>? = null
private var pendingImportTableName: String? = null
private val saveFileLauncher = registerForActivityResult( private val saveFileLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
@@ -53,6 +56,16 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() {
pendingExportData = null 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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.debug_preferences_database_table, rootKey) 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_EXPORT_TABLE) { startTableExport(tableName) }
onClick(PREF_DEBUG_IMPORT_TABLE) { startTableImport(tableName) }
onClick(PREF_DEBUG_CLEAR_TABLE) { clearTable(tableName) } onClick(PREF_DEBUG_CLEAR_TABLE) { clearTable(tableName) }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@@ -107,7 +122,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() {
cursor.use { cursor.use {
it.moveToNext() it.moveToNext()
findPreference<Preference>(PREF_DEBUG_DATABASE_COUNT)?.summary = it.getInt(it.getColumnIndexOrThrow("count")).toString() findPreference<Preference>(PREF_DEBUG_DATABASE_COUNT)?.summary =
it.getInt(it.getColumnIndexOrThrow("count")).toString()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -400,7 +416,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() {
val value = when (it.getType(index)) { val value = when (it.getType(index)) {
android.database.Cursor.FIELD_TYPE_BLOB -> android.database.Cursor.FIELD_TYPE_BLOB ->
"<blob:${it.getBlob(index).joinToString("") { b -> "%02x".format(b) }}>" "<blob:${it.getBlob(index).joinToString("") { b -> "%02x".format(b) }}>"
else -> it.getString(index) ?: ""
else -> it.getString(index) ?: NULL_FIELD_CSV_VALUE
} }
escapeValueForCsv(value) escapeValueForCsv(value)
} else { } 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("<blob:") && value.endsWith(">") -> {
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<String> {
GBApplication.acquireDB().use { db ->
val cursor = db.database.rawQuery("PRAGMA table_info($tableName)", null)
val columns = mutableSetOf<String>()
cursor.use {
while (it.moveToNext()) {
columns.add(it.getString(it.getColumnIndexOrThrow("name")))
}
}
return columns
}
}
private fun parseBlobHex(value: String): ByteArray? {
val hex = value.removePrefix("<blob:").removeSuffix(">")
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<String> {
val fields = mutableListOf<String>()
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) { private fun clearTable(tableName: String) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setCancelable(true) .setCancelable(true)
@@ -488,8 +672,11 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() {
companion object { companion object {
private val LOG = LoggerFactory.getLogger(DatabaseTableDebugFragment::class.java) private val LOG = LoggerFactory.getLogger(DatabaseTableDebugFragment::class.java)
private const val NULL_FIELD_CSV_VALUE = "<null>"
private const val PREF_DEBUG_DATABASE_COUNT = "pref_debug_database_count" 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_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_HEADER_DANGEROUS_ACTIONS = "pref_header_dangerous_actions"
private const val PREF_DEBUG_CLEAR_TABLE = "pref_debug_clear_table" private const val PREF_DEBUG_CLEAR_TABLE = "pref_debug_clear_table"
private const val PREF_DEBUG_DROP_TABLE = "pref_debug_drop_table" private const val PREF_DEBUG_DROP_TABLE = "pref_debug_drop_table"
@@ -30,11 +30,15 @@ class DeviceDebugFragment : AbstractDebugFragment() {
gbDevice = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { gbDevice = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
arguments?.getParcelable(GBDevice.EXTRA_DEVICE, GBDevice::class.java)!! arguments?.getParcelable(GBDevice.EXTRA_DEVICE, GBDevice::class.java)!!
} else { } else {
arguments?.getParcelable<GBDevice>(GBDevice.EXTRA_DEVICE)!! arguments?.getParcelable(GBDevice.EXTRA_DEVICE)!!
} }
preferenceScreen?.title = gbDevice.aliasOrName preferenceScreen?.title = gbDevice.aliasOrName
findPreference<Preference>(PREF_DEBUG_DEVICE_ID)?.summary = GBApplication.acquireDbReadOnly().use { dbHandler ->
DBHelper.findDevice(gbDevice, dbHandler.daoSession)?.id?.toString() ?: "<null>"
}
findPreference<Preference>(PREF_DEBUG_DEVICE_NAME)?.summary = gbDevice.name ?: "<null>" findPreference<Preference>(PREF_DEBUG_DEVICE_NAME)?.summary = gbDevice.name ?: "<null>"
if (gbDevice.alias.isNullOrEmpty()) { if (gbDevice.alias.isNullOrEmpty()) {
findPreference<Preference>(PREF_DEBUG_DEVICE_ALIAS)?.summary = getString(R.string.not_set) findPreference<Preference>(PREF_DEBUG_DEVICE_ALIAS)?.summary = getString(R.string.not_set)
@@ -130,6 +134,7 @@ class DeviceDebugFragment : AbstractDebugFragment() {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
editorNew.putStringSet(key, value as Set<String>) editorNew.putStringSet(key, value as Set<String>)
} }
else -> { else -> {
LOG.error("Unexpected preference type {}", value?.javaClass) LOG.error("Unexpected preference type {}", value?.javaClass)
GB.toast("Failed to copy settings", Toast.LENGTH_LONG, GB.ERROR) GB.toast("Failed to copy settings", Toast.LENGTH_LONG, GB.ERROR)
@@ -204,6 +209,7 @@ class DeviceDebugFragment : AbstractDebugFragment() {
companion object { companion object {
private val LOG: Logger = LoggerFactory.getLogger(DeviceDebugFragment::class.java) 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_NAME = "pref_debug_device_name"
private const val PREF_DEBUG_DEVICE_ALIAS = "pref_debug_device_alias" private const val PREF_DEBUG_DEVICE_ALIAS = "pref_debug_device_alias"
private const val PREF_DEBUG_DEVICE_MAC_ADDRESS = "pref_debug_device_mac_address" private const val PREF_DEBUG_DEVICE_MAC_ADDRESS = "pref_debug_device_mac_address"
+1
View File
@@ -1239,6 +1239,7 @@
<string name="prefs_always_on_display_follow_watchface">Style follows Watchface</string> <string name="prefs_always_on_display_follow_watchface">Style follows Watchface</string>
<string name="prefs_always_on_display_style">Style</string> <string name="prefs_always_on_display_style">Style</string>
<string name="prefs_always_on_display_summary">Keep the band\'s display always on</string> <string name="prefs_always_on_display_summary">Keep the band\'s display always on</string>
<string name="prefs_device_id">Device ID</string>
<string name="prefs_device_name">Device name</string> <string name="prefs_device_name">Device name</string>
<string name="prefs_device_alias">Device alias</string> <string name="prefs_device_alias">Device alias</string>
<string name="prefs_password">Password</string> <string name="prefs_password">Password</string>
@@ -16,6 +16,12 @@
android:persistent="false" android:persistent="false"
android:title="@string/file_export" /> android:title="@string/file_export" />
<Preference
android:icon="@drawable/ic_download"
android:key="pref_debug_import_table"
android:persistent="false"
android:title="@string/file_import" />
<PreferenceCategory <PreferenceCategory
android:key="pref_header_dangerous_actions" android:key="pref_header_dangerous_actions"
android:title="@string/dangerous_actions"> android:title="@string/dangerous_actions">
@@ -8,6 +8,12 @@
android:title="@string/prefs_device_name" android:title="@string/prefs_device_name"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<Preference
android:key="pref_debug_device_id"
android:persistent="false"
android:title="@string/prefs_device_id"
app:iconSpaceReserved="false" />
<Preference <Preference
android:key="pref_debug_device_alias" android:key="pref_debug_device_alias"
android:persistent="false" android:persistent="false"