From 7318e676f706d232a6be281b3323f28f3613ff28 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 25 May 2023 11:27:36 -0400 Subject: [PATCH] Add an internal feature to search your contacts by ID/ACI/PNI. --- .../app/internal/InternalSettingsFragment.kt | 8 + .../internal/search/InternalSearchFragment.kt | 140 ++++++++++++++++++ .../internal/search/InternalSearchResult.kt | 17 +++ .../search/InternalSearchViewModel.kt | 83 +++++++++++ .../securesms/database/RecipientTable.kt | 16 ++ app/src/main/res/navigation/app_settings.xml | 8 + 6 files changed, 272 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchViewModel.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 aa8fa3fed4..30fc9d9ec2 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 @@ -128,6 +128,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter 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/search/InternalSearchFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchFragment.kt new file mode 100644 index 0000000000..a92a59dbe7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchFragment.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.thoughtcrime.securesms.components.settings.app.internal.search + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import java.util.UUID + +class InternalSearchFragment : ComposeFragment() { + + val viewModel: InternalSearchViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val results: ImmutableList by viewModel.results + val query: String by viewModel.query + + InternalSearchFragmentScreen( + query = query, + results = results, + onSearchUpdated = { viewModel.onQueryChanged(it) } + ) + } +} + +@Composable +fun InternalSearchFragmentScreen(query: String, results: ImmutableList, onSearchUpdated: (String) -> Unit, modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + item(key = -1) { + SearchBar(query, onSearchUpdated) + } + results.forEach { recipient -> + item(key = recipient.id) { + ResultItem(recipient) + } + } + } +} + +@Composable +fun SearchBar(query: String, onSearchUpdated: (String) -> Unit, modifier: Modifier = Modifier) { + TextField( + value = query, + onValueChange = onSearchUpdated, + placeholder = { Text(text = "Search by ID, ACI, or PNI") }, + modifier = modifier.fillMaxWidth() + ) +} + +@Composable +fun ResultItem(result: InternalSearchResult, modifier: Modifier = Modifier) { + val activity = LocalContext.current as? AppCompatActivity + + Column( + modifier = modifier + .fillMaxWidth() + .clickable { + if (activity != null) { + RecipientBottomSheetDialogFragment.create(result.id, result.groupId).show(activity.supportFragmentManager, "TAG") + } + } + .padding(8.dp) + ) { + Text(text = result.name, style = MaterialTheme.typography.titleSmall) + Text(text = "ID: ${result.id}") + Text(text = "ACI: ${result.aci ?: "null"}") + Text(text = "PNI: ${result.pni ?: "null"}") + } +} + +@Preview +@Composable +fun InternalSearchScreenPreviewLightTheme() { + SignalTheme(isDarkMode = false) { + Surface { + InternalSearchScreenPreview() + } + } +} + +@Preview +@Composable +fun InternalSearchScreenPreviewDarkTheme() { + SignalTheme(isDarkMode = true) { + Surface { + InternalSearchScreenPreview() + } + } +} + +@Composable +fun InternalSearchScreenPreview() { + InternalSearchFragmentScreen( + query = "", + results = persistentListOf( + InternalSearchResult( + name = "Peter Parker", + id = RecipientId.from(1), + aci = UUID.randomUUID().toString(), + pni = UUID.randomUUID().toString() + ), + InternalSearchResult( + name = "Mary Jane", + id = RecipientId.from(2), + aci = UUID.randomUUID().toString(), + pni = null + ) + ), + onSearchUpdated = {} + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchResult.kt new file mode 100644 index 0000000000..0096b2b564 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchResult.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.search + +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.RecipientId + +data class InternalSearchResult( + val name: String, + val id: RecipientId, + val aci: String? = null, + val pni: String? = null, + val groupId: GroupId? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchViewModel.kt new file mode 100644 index 0000000000..87c15d5c28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/search/InternalSearchViewModel.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.search + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RecipientRecord +import java.util.concurrent.TimeUnit + +class InternalSearchViewModel : ViewModel() { + + private val _results: MutableState> = mutableStateOf(persistentListOf()) + val results: State> = _results + + private val _query: MutableState = mutableStateOf("") + val query: State = _query + + private val disposable: CompositeDisposable = CompositeDisposable() + + private val querySubject: BehaviorSubject = BehaviorSubject.create() + + init { + disposable += querySubject + .distinctUntilChanged() + .debounce(250, TimeUnit.MILLISECONDS, Schedulers.io()) + .observeOn(Schedulers.io()) + .map { query -> + SignalDatabase.recipients.queryByInternalFields(query) + .map { record -> + InternalSearchResult( + id = record.id, + name = record.displayName(), + aci = record.serviceId?.toString(), + pni = record.pni.toString(), + groupId = record.groupId + ) + } + .toImmutableList() + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { results -> + _results.value = results + } + } + + fun onQueryChanged(value: String) { + _query.value = value + querySubject.onNext(value) + } + + override fun onCleared() { + disposable.clear() + } + + private fun RecipientRecord.displayName(): String { + return when { + this.groupType == RecipientTable.GroupType.SIGNAL_V1 -> "GV1::${this.groupId}" + this.groupType == RecipientTable.GroupType.SIGNAL_V2 -> "GV2::${this.groupId}" + this.groupType == RecipientTable.GroupType.MMS -> "MMS_GROUP::${this.groupId}" + this.groupType == RecipientTable.GroupType.DISTRIBUTION_LIST -> "DLIST::${this.distributionListId}" + this.systemDisplayName?.isNotBlank() == true -> this.systemDisplayName + this.signalProfileName.toString().isNotBlank() -> this.signalProfileName.serialize() + this.e164 != null -> this.e164 + else -> "Unknown" + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 4c7eef4b0e..27fc27e5b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -23,6 +23,7 @@ import org.signal.core.util.optionalInt import org.signal.core.util.optionalLong import org.signal.core.util.optionalString import org.signal.core.util.or +import org.signal.core.util.readToList import org.signal.core.util.readToSet import org.signal.core.util.readToSingleBoolean import org.signal.core.util.readToSingleLong @@ -3140,6 +3141,21 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + fun queryByInternalFields(query: String): List { + if (query.isBlank()) { + return emptyList() + } + + return readableDatabase + .select() + .from(TABLE_NAME) + .where("$ID LIKE ? OR $SERVICE_ID LIKE ? OR $PNI_COLUMN LIKE ?", "%$query%", "%$query%", "%$query%") + .run() + .readToList { cursor -> + getRecord(context, cursor) + } + } + fun getSignalContacts(includeSelf: Boolean): Cursor? { return getSignalContacts(includeSelf, "$SORT_NAME, $SYSTEM_JOINED_NAME, $SEARCH_PROFILE_NAME, $USERNAME, $PHONE") } diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 779d0bff96..9834c00f4f 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -581,6 +581,9 @@ + + +