mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Debug: Add device ID, allow table import from CSV
This commit is contained in:
+189
-2
@@ -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<String, Date, Date>? = 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<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) {
|
||||
@@ -400,7 +416,8 @@ class DatabaseTableDebugFragment : AbstractDebugFragment() {
|
||||
val value = when (it.getType(index)) {
|
||||
android.database.Cursor.FIELD_TYPE_BLOB ->
|
||||
"<blob:${it.getBlob(index).joinToString("") { b -> "%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("<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) {
|
||||
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 = "<null>"
|
||||
|
||||
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"
|
||||
|
||||
+7
-1
@@ -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>(GBDevice.EXTRA_DEVICE)!!
|
||||
arguments?.getParcelable(GBDevice.EXTRA_DEVICE)!!
|
||||
}
|
||||
|
||||
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>"
|
||||
if (gbDevice.alias.isNullOrEmpty()) {
|
||||
findPreference<Preference>(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<String>)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -1239,6 +1239,7 @@
|
||||
<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_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_alias">Device alias</string>
|
||||
<string name="prefs_password">Password</string>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
android:persistent="false"
|
||||
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
|
||||
android:key="pref_header_dangerous_actions"
|
||||
android:title="@string/dangerous_actions">
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
android:title="@string/prefs_device_name"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<Preference
|
||||
android:key="pref_debug_device_id"
|
||||
android:persistent="false"
|
||||
android:title="@string/prefs_device_id"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<Preference
|
||||
android:key="pref_debug_device_alias"
|
||||
android:persistent="false"
|
||||
|
||||
Reference in New Issue
Block a user