Add an internal feature to search your contacts by ID/ACI/PNI.

This commit is contained in:
Greyson Parrelli 2023-05-25 11:27:36 -04:00 committed by Cody Henthorne
parent 7c28d8ad51
commit 7318e676f7
6 changed files with 272 additions and 0 deletions

View file

@ -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."),

View file

@ -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<InternalSearchResult> 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<InternalSearchResult>, 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 = {}
)
}

View file

@ -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
)

View file

@ -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<ImmutableList<InternalSearchResult>> = mutableStateOf(persistentListOf())
val results: State<ImmutableList<InternalSearchResult>> = _results
private val _query: MutableState<String> = mutableStateOf("")
val query: State<String> = _query
private val disposable: CompositeDisposable = CompositeDisposable()
private val querySubject: BehaviorSubject<String> = 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"
}
}
}

View file

@ -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<RecipientRecord> {
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")
}

View file

@ -581,6 +581,9 @@
<action
android:id="@+id/action_internalSettingsFragment_to_storyDialogsLauncherFragment"
app:destination="@id/storyDialogsLauncherFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalSearchFragment"
app:destination="@id/internalSearchFragment" />
</fragment>
<fragment
@ -593,6 +596,11 @@
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalStoryDialogLauncherFragment"
android:label="story_dialogs_launcher_fragment" />
<fragment
android:id="@+id/internalSearchFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.search.InternalSearchFragment"
android:label="internal_search_fragment" />
<!-- endregion -->
<!-- Subscriptions -->