From 1f91ed42741e6a3648bcf95ebb12628fba5968e0 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 25 Nov 2024 12:22:52 -0500 Subject: [PATCH] Add an internal sqlite playground. --- .../app/internal/InternalSettingsFragment.kt | 37 ++-- .../InternalSqlitePlaygroundFragment.kt | 209 ++++++++++++++++++ .../InternalSqlitePlaygroundViewModel.kt | 68 ++++++ .../app_settings_with_change_number.xml | 8 + 4 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index e8b765d97c..a77da4138d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -149,24 +149,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter onUnregisterClicked() } ) - dividerPref() - sectionHeaderPref(DSLSettingsText.from("Miscellaneous")) + sectionHeaderPref(DSLSettingsText.from("Playgrounds")) clickPref( - title = DSLSettingsText.from("Search for a recipient"), - summary = DSLSettingsText.from("Search by ID, ACI, or PNI."), + title = DSLSettingsText.from("SQLite Playground"), + summary = DSLSettingsText.from("Run raw SQLite queries."), onClick = { - findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSearchFragment()) - } - ) - - clickPref( - title = DSLSettingsText.from("SVR Playground"), - summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."), - onClick = { - findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment()) + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSqlitePlaygroundFragment()) } ) @@ -186,6 +177,26 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("SVR Playground"), + summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment()) + } + ) + + dividerPref() + + sectionHeaderPref(DSLSettingsText.from("Miscellaneous")) + + clickPref( + title = DSLSettingsText.from("Search for a recipient"), + summary = DSLSettingsText.from("Search by ID, ACI, or PNI."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSearchFragment()) + } + ) + switchPref( title = DSLSettingsText.from("'Internal Details' button"), summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundFragment.kt new file mode 100644 index 0000000000..4981663354 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundFragment.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.sqlite + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.libsignal.protocol.util.Hex +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundViewModel.QueryResult +import org.thoughtcrime.securesms.compose.ComposeFragment + +class InternalSqlitePlaygroundFragment : ComposeFragment() { + + val viewModel by viewModels() + + @Composable + override fun FragmentContent() { + val queryResults by viewModel.queryResults + + Screen( + onBackPressed = { findNavController().popBackStack() }, + queryResults = queryResults, + onQuerySubmitted = { viewModel.onQuerySubmitted(it) } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Screen( + onBackPressed: () -> Unit = {}, + queryResults: QueryResult?, + onQuerySubmitted: (String) -> Unit = {} +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("SQLite Playground") }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + painter = painterResource(R.drawable.symbol_arrow_left_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null + ) + } + } + ) + } + ) { contentPadding -> + Surface(modifier = Modifier.padding(contentPadding)) { + Column(modifier = Modifier.padding(8.dp)) { + Text("Warning! This allows you to run arbitrary queries. Only use this if you know what you're doing!", color = Color.Red) + Spacer(Modifier.height(8.dp)) + QueryBox(onQuerySubmitted) + Spacer(Modifier.height(8.dp)) + QueryResults(queryResults) + } + } + } +} + +@Composable +private fun QueryBox(onQuerySubmitted: (String) -> Unit = {}) { + var queryText: String by remember { mutableStateOf("") } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + OutlinedTextField( + value = queryText, + onValueChange = { queryText = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + Spacer(Modifier.height(2.dp)) + Buttons.LargePrimary(onClick = { onQuerySubmitted(queryText) }) { + Text("Execute") + } + } +} + +@Composable +private fun QueryResults(results: QueryResult?) { + val columnWidth = LocalConfiguration.current.screenWidthDp.dp / 2 + val horizontalScrollState = rememberScrollState() + + if (results == null) { + Text("Waiting on query results.") + return + } + Text("${results.rows.size} rows in ${results.totalTimeString} ms", modifier = Modifier.padding(4.dp)) + QueryRow(data = results.columns, columnWidth = columnWidth, scrollState = horizontalScrollState, fontWeight = FontWeight.Bold) + + LazyColumn { + items(results.rows) { row -> + QueryRow(data = row, columnWidth = columnWidth, scrollState = horizontalScrollState) + } + } +} + +@Composable +private fun QueryRow(data: List, columnWidth: Dp, scrollState: ScrollState, fontWeight: FontWeight = FontWeight.Normal) { + val context = LocalContext.current + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + ) { + data.forEach { + Text( + text = it.toDisplayString(), + fontWeight = fontWeight, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.width(columnWidth) + .padding(4.dp) + .clickable { + MaterialAlertDialogBuilder(context) + .setMessage(it.toDisplayString()) + .setPositiveButton("Ok", null) + .show() + } + ) + } + } +} + +private fun Any?.toDisplayString(): String { + return when (this) { + is ByteArray -> "Blob { ${Hex.toStringCondensed(this)} }" + else -> this.toString() + } +} + +@SignalPreview +@Composable +private fun ScreenPreview() { + Previews.Preview { + Screen( + queryResults = QueryResult( + columns = listOf("column1", "column2", "column3"), + rows = listOf( + listOf("a", 1, ByteArray(16) { 0 }), + listOf("b", 2, ByteArray(16) { 1 }), + listOf("c", 3, ByteArray(16) { 2 }) + ), + totalTimeString = "3.42" + ) + ) + } +} + +@SignalPreview +@Composable +private fun ScreenPreviewNoResults() { + Previews.Preview { + Screen( + queryResults = null + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundViewModel.kt new file mode 100644 index 0000000000..4c2c19cc7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/sqlite/InternalSqlitePlaygroundViewModel.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.sqlite + +import android.database.Cursor +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.signal.core.util.ExceptionUtil +import org.signal.core.util.readToList +import org.signal.core.util.roundedString +import org.thoughtcrime.securesms.database.SignalDatabase +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.DurationUnit + +class InternalSqlitePlaygroundViewModel : ViewModel() { + + private val _queryResults: MutableState = mutableStateOf(null) + val queryResults: State + get() = _queryResults + + fun onQuerySubmitted(query: String) { + viewModelScope.launch(Dispatchers.IO) { + _queryResults.value = null + + val startTime = System.nanoTime() + try { + SignalDatabase.rawDatabase.rawQuery(query).use { cursor -> + val columnNames = cursor.columnNames.toList() + val rows: List> = cursor.readToList { row -> + val out: MutableList = ArrayList(row.columnCount) + for (i in 0 until row.columnCount) { + if (row.getType(i) == Cursor.FIELD_TYPE_BLOB) { + out.add(row.getBlob(i)) + } else { + out.add(row.getString(i)) + } + } + out + } + + val endTime = System.nanoTime() + val timeMs: String = (endTime - startTime).nanoseconds.toDouble(DurationUnit.MILLISECONDS).roundedString(2) + _queryResults.value = QueryResult(columns = columnNames, rows = rows, totalTimeString = timeMs) + } + } catch (e: Exception) { + _queryResults.value = QueryResult( + columns = listOf("Query failed!"), + rows = listOf(listOf(ExceptionUtil.convertThrowableToString(e))), + totalTimeString = "" + ) + } + } + } + + data class QueryResult( + val columns: List, + val rows: List>, + val totalTimeString: String + ) +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 99a7f55b41..5a4ebfbe74 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -751,6 +751,9 @@ + + +