Add in-call info sheet for call links.

This commit is contained in:
Alex Hart
2023-06-08 11:44:33 -03:00
committed by Cody Henthorne
parent 369ca189d3
commit 886c149c3f
20 changed files with 452 additions and 69 deletions
+1
View File
@@ -470,6 +470,7 @@ dependencies {
implementation libs.androidx.gridlayout implementation libs.androidx.gridlayout
implementation libs.androidx.exifinterface implementation libs.androidx.exifinterface
implementation libs.androidx.compose.rxjava3 implementation libs.androidx.compose.rxjava3
implementation libs.androidx.compose.runtime.livedata
implementation libs.androidx.constraintlayout implementation libs.androidx.constraintlayout
implementation libs.androidx.multidex implementation libs.androidx.multidex
implementation libs.androidx.navigation.fragment.ktx implementation libs.androidx.navigation.fragment.ktx
@@ -55,6 +55,7 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor; import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow; import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow; import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
@@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity; import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity;
import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
@@ -901,7 +903,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override @Override
public void onCallInfoClicked() { public void onCallInfoClicked() {
CallParticipantsListDialog.show(getSupportFragmentManager()); LiveRecipient liveRecipient = viewModel.getRecipient();
if (liveRecipient.get().isCallLink()) {
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
} else {
CallParticipantsListDialog.show(getSupportFragmentManager());
}
} }
@Override @Override
@@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.avatar.text package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged
import org.thoughtcrime.securesms.avatar.Avatar import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.util.livedata.Store
@@ -12,7 +12,7 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
private val store = Store(TextAvatarCreationState(initialText)) private val store = Store(TextAvatarCreationState(initialText))
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData) val state: LiveData<TextAvatarCreationState> = store.stateLiveData.distinctUntilChanged()
fun setColor(colorPair: Avatars.ColorPair) { fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) } store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
@@ -76,7 +76,7 @@ private fun SignalCallRowPreview() {
@Composable @Composable
fun SignalCallRow( fun SignalCallRow(
callLink: CallLinkTable.CallLink, callLink: CallLinkTable.CallLink,
onJoinClicked: () -> Unit, onJoinClicked: (() -> Unit)?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@@ -122,13 +122,15 @@ fun SignalCallRow(
) )
} }
Spacer(modifier = Modifier.width(10.dp)) if (onJoinClicked != null) {
Spacer(modifier = Modifier.width(10.dp))
Buttons.Small( Buttons.Small(
onClick = onJoinClicked, onClick = onJoinClicked,
modifier = Modifier.align(CenterVertically) modifier = Modifier.align(CenterVertically)
) { ) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join)) Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
}
} }
} }
} }
@@ -34,6 +34,7 @@ import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows import org.signal.core.ui.Rows
@@ -86,7 +87,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
) { ) {
val callLink: CallLinkTable.CallLink by viewModel.callLink val callLink: CallLinkTable.CallLink by viewModel.callLink
Handle(modifier = Modifier.align(Alignment.CenterHorizontally)) BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
@@ -3,9 +3,10 @@ package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor import android.database.Cursor
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -62,7 +63,7 @@ sealed class ConversationSettingsViewModel(
protected val disposable = CompositeDisposable() protected val disposable = CompositeDisposable()
init { init {
val threadId: LiveData<Long> = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId }) val threadId: LiveData<Long> = state.map { it.threadId }.distinctUntilChanged()
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId } val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId -> val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
@@ -0,0 +1,332 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ShareCompat
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragmentArgs
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Displays information about the in-progress CallLink call from
* within WebRtcActivity. If the user is able to modify call link
* state, provides options to do so.
*/
class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
companion object {
private val TAG = Log.tag(CallLinkInfoSheet::class.java)
private const val CALL_LINK_ROOM_ID = "call_link_room_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, callLinkRoomId: CallLinkRoomId) {
CallLinkInfoSheet().apply {
arguments = bundleOf(CALL_LINK_ROOM_ID to callLinkRoomId)
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val webRtcCallViewModel: WebRtcCallViewModel by activityViewModels()
private val callLinkDetailsViewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(BundleCompat.getParcelable(requireArguments(), CALL_LINK_ROOM_ID, CallLinkRoomId::class.java)!!)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun SheetContent() {
val callLinkDetailsState by callLinkDetailsViewModel.state
val callParticipantsState by webRtcCallViewModel.callParticipantsState.observeAsState()
val participants = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
listOf(Recipient.self()) + (callParticipantsState?.allRemoteParticipants?.map { it.recipient } ?: emptyList())
} else {
emptyList()
}.toImmutableList()
val onEditNameClicked: () -> Unit = remember(callLinkDetailsState) {
{
EditCallLinkNameDialogFragment().apply {
arguments = EditCallLinkNameDialogFragmentArgs.Builder(callLinkDetailsState.callLink?.state?.name ?: "").build().toBundle()
}.show(parentFragmentManager, null)
}
}
val callLink = callLinkDetailsState.callLink
if (callLink != null) {
Sheet(
callLink = callLink,
participants = participants,
onShareLinkClicked = this::shareLink,
onEditNameClicked = onEditNameClicked,
onToggleAdminApprovalClicked = this::onApproveAllMembersChanged,
onBlock = {} // TODO [alex] -- Blocking
)
}
}
private fun onApproveAllMembersChanged(checked: Boolean) {
callLinkDetailsViewModel.setApproveAllMembers(checked)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
.addTo(lifecycleDisposable)
}
private fun shareLink() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(callLinkDetailsViewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
private fun setName(name: String) {
callLinkDetailsViewModel.setName(name)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
},
onError = handleError("setName")
)
.addTo(lifecycleDisposable)
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}
@Preview
@Composable
private fun SheetPreview() {
SignalTheme(isDarkMode = true) {
Surface {
Sheet(
callLink = CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4, 5)),
credentials = CallLinkCredentials(
linkKeyBytes = byteArrayOf(1, 2, 3, 4, 5),
adminPassBytes = byteArrayOf(1, 2, 3, 4, 5)
),
state = SignalCallLinkState(),
avatarColor = AvatarColor.random()
),
participants = listOf(Recipient.UNKNOWN).toImmutableList(),
onShareLinkClicked = {},
onEditNameClicked = {},
onToggleAdminApprovalClicked = {},
onBlock = {}
)
}
}
}
@Composable
private fun Sheet(
callLink: CallLinkTable.CallLink,
participants: ImmutableList<Recipient>,
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onToggleAdminApprovalClicked: (Boolean) -> Unit,
onBlock: (Recipient) -> Unit
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
BottomSheets.Handle()
SignalCallRow(callLink = callLink, onJoinClicked = null)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
iconModifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
.size(42.dp)
.padding(9.dp),
modifier = Modifier
.defaultMinSize(minHeight = 64.dp)
.clickable(onClick = onShareLinkClicked)
)
}
items(participants, { it.id }, { null }) {
CallLinkMemberRow(
recipient = it,
isSelfAdmin = callLink.credentials?.adminPassBytes != null,
onBlockClicked = onBlock
)
}
if (callLink.credentials?.adminPassBytes != null) {
item {
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
modifier = Modifier.clickable(onClick = onEditNameClicked)
)
Rows.ToggleRow(
checked = callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = onToggleAdminApprovalClicked
)
}
}
}
}
@Preview
@Composable
private fun CallLinkMemberRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallLinkMemberRow(
Recipient.UNKNOWN,
isSelfAdmin = true,
{}
)
}
}
}
@Composable
private fun CallLinkMemberRow(
recipient: Recipient,
isSelfAdmin: Boolean,
onBlockClicked: (Recipient) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(recipient)
}
Spacer(modifier = Modifier.width(24.dp))
Text(
text = recipient.getShortDisplayName(LocalContext.current),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
if (isSelfAdmin) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
contentDescription = null,
modifier = Modifier
.clickable(onClick = { onBlockClicked(recipient) })
.align(Alignment.CenterVertically)
)
}
}
}
@@ -30,6 +30,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.BottomSheets
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -49,7 +50,7 @@ class WebRtcAudioOutputBottomSheet : ComposeBottomSheetDialogFragment(), DialogI
.padding(16.dp) .padding(16.dp)
.wrapContentSize() .wrapContentSize()
) { ) {
Handle() BottomSheets.Handle()
DeviceList(audioOutputOptions = viewModel.audioRoutes.toImmutableList(), initialDeviceId = viewModel.defaultDeviceId, modifier = Modifier.fillMaxWidth(), onDeviceSelected = viewModel.onClick) DeviceList(audioOutputOptions = viewModel.audioRoutes.toImmutableList(), initialDeviceId = viewModel.defaultDeviceId, modifier = Modifier.fillMaxWidth(), onDeviceSelected = viewModel.onClick)
} }
} }
@@ -4,16 +4,9 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
@@ -41,29 +34,4 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD
@Composable @Composable
abstract fun SheetContent() abstract fun SheetContent()
/**
* BottomSheet Handle, according to our design specs.
* This can be placed in a column with the other page content like so:
*
* ```
* Column(modifier = Modifier
* .fillMaxWidth()
* .wrapContentSize(Alignment.Center)
* ) {
* Handle()
* Text("Hello!")
* }
* ```
*/
@Composable
protected fun Handle(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(width = 48.dp, height = 22.dp)
.padding(vertical = 10.dp)
.clip(RoundedCornerShape(1000.dp))
.background(MaterialTheme.colorScheme.outline)
)
}
} }
@@ -2,9 +2,10 @@ package org.thoughtcrime.securesms.contacts.paged
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
@@ -48,8 +49,8 @@ class ContactSearchViewModel(
private val selectionStore = Store<Set<ContactSearchKey>>(emptySet()) private val selectionStore = Store<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>() private val errorEvents = PublishSubject.create<ContactSearchError>()
val controller: LiveData<PagingController<ContactSearchKey>> = Transformations.map(pagedData) { it.controller } val controller: LiveData<PagingController<ContactSearchKey>> = pagedData.map { it.controller }
val data: LiveData<List<ContactSearchData>> = Transformations.switchMap(pagedData) { it.data } val data: LiveData<List<ContactSearchData>> = pagedData.switchMap { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
val selectionState: LiveData<Set<ContactSearchKey>> = selectionStore.stateLiveData val selectionState: LiveData<Set<ContactSearchKey>> = selectionStore.stateLiveData
val errorEventsStream: Observable<ContactSearchError> = errorEvents val errorEventsStream: Observable<ContactSearchError> = errorEvents
@@ -1,7 +1,8 @@
package org.thoughtcrime.securesms.conversation.colors package org.thoughtcrime.securesms.conversation.colors
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import com.annimon.stream.Stream import com.annimon.stream.Stream
import org.signal.core.util.MapUtil import org.signal.core.util.MapUtil
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette.Names.all import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette.Names.all
@@ -23,13 +24,13 @@ object NameColors {
recipientId: LiveData<RecipientId>, recipientId: LiveData<RecipientId>,
sessionMemberCache: MutableMap<GroupId, Set<Recipient>> sessionMemberCache: MutableMap<GroupId, Set<Recipient>>
): LiveData<Map<RecipientId, NameColor>> { ): LiveData<Map<RecipientId, NameColor>> {
val recipient = Transformations.switchMap(recipientId) { r: RecipientId? -> Recipient.live(r!!).liveData } val recipient = recipientId.switchMap { r: RecipientId? -> Recipient.live(r!!).liveData }
val group = Transformations.map(recipient) { obj: Recipient -> obj.groupId } val group = recipient.map { obj: Recipient -> obj.groupId }
val groupMembers = Transformations.switchMap(group) { g: Optional<GroupId> -> val groupMembers = group.switchMap { g: Optional<GroupId> ->
g.map { groupId: GroupId -> this.getSessionGroupRecipients(groupId, sessionMemberCache) } g.map { groupId: GroupId -> this.getSessionGroupRecipients(groupId, sessionMemberCache) }
.orElseGet { DefaultValueLiveData(emptySet()) } .orElseGet { DefaultValueLiveData(emptySet()) }
} }
return Transformations.map(groupMembers) { members: Set<Recipient>? -> return groupMembers.map { members: Set<Recipient>? ->
val sorted = Stream.of(members) val sorted = Stream.of(members)
.filter { member: Recipient? -> member != Recipient.self() } .filter { member: Recipient? -> member != Recipient.self() }
.sortBy { obj: Recipient -> obj.requireStringId() } .sortBy { obj: Recipient -> obj.requireStringId() }
@@ -44,14 +45,13 @@ object NameColors {
} }
private fun getSessionGroupRecipients(groupId: GroupId, sessionMemberCache: MutableMap<GroupId, Set<Recipient>>): LiveData<Set<Recipient>> { private fun getSessionGroupRecipients(groupId: GroupId, sessionMemberCache: MutableMap<GroupId, Set<Recipient>>): LiveData<Set<Recipient>> {
val fullMembers = Transformations.map( val fullMembers = LiveGroup(groupId).fullMembers.map { members: List<FullMember>? ->
LiveGroup(groupId).fullMembers
) { members: List<FullMember>? ->
Stream.of(members) Stream.of(members)
.map { it.member } .map { it.member }
.toList() .toList()
} }
return Transformations.map(fullMembers) { currentMembership: List<Recipient>? ->
return fullMembers.map { currentMembership: List<Recipient>? ->
val cachedMembers: MutableSet<Recipient> = MapUtil.getOrDefault(sessionMemberCache, groupId, HashSet()).toMutableSet() val cachedMembers: MutableSet<Recipient> = MapUtil.getOrDefault(sessionMemberCache, groupId, HashSet()).toMutableSet()
cachedMembers.addAll(currentMembership!!) cachedMembers.addAll(currentMembership!!)
sessionMemberCache[groupId] = cachedMembers sessionMemberCache[groupId] = cachedMembers
@@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
@@ -35,7 +36,7 @@ class RelinkDevicesReminderBottomSheetFragment : ComposeBottomSheetDialogFragmen
.padding(16.dp) .padding(16.dp)
.wrapContentSize() .wrapContentSize()
) { ) {
Handle() BottomSheets.Handle()
Column(horizontalAlignment = Alignment.Start) { Column(horizontalAlignment = Alignment.Start) {
Text( Text(
text = stringResource(id = R.string.RelinkDevicesReminderFragment__relink_your_devices), text = stringResource(id = R.string.RelinkDevicesReminderFragment__relink_your_devices),
@@ -4,7 +4,7 @@ import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.map
import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.InvalidProtocolBufferException
import com.mobilecoin.lib.Mnemonics import com.mobilecoin.lib.Mnemonics
import com.mobilecoin.lib.exceptions.BadMnemonicException import com.mobilecoin.lib.exceptions.BadMnemonicException
@@ -70,7 +70,7 @@ internal class PaymentsValues internal constructor(store: KeyValueStore) : Signa
private val liveCurrentCurrency: MutableLiveData<Currency> by lazy { MutableLiveData(currentCurrency()) } private val liveCurrentCurrency: MutableLiveData<Currency> by lazy { MutableLiveData(currentCurrency()) }
private val enclaveFailure: MutableLiveData<Boolean> by lazy { MutableLiveData(false) } private val enclaveFailure: MutableLiveData<Boolean> by lazy { MutableLiveData(false) }
private val liveMobileCoinLedger: MutableLiveData<MobileCoinLedgerWrapper> by lazy { MutableLiveData(mobileCoinLatestFullLedger()) } private val liveMobileCoinLedger: MutableLiveData<MobileCoinLedgerWrapper> by lazy { MutableLiveData(mobileCoinLatestFullLedger()) }
private val liveMobileCoinBalance: LiveData<Balance> by lazy { Transformations.map(liveMobileCoinLedger) { obj: MobileCoinLedgerWrapper -> obj.balance } } private val liveMobileCoinBalance: LiveData<Balance> by lazy { liveMobileCoinLedger.map { obj: MobileCoinLedgerWrapper -> obj.balance } }
public override fun onFirstEverAppLaunch() {} public override fun onFirstEverAppLaunch() {}
@@ -9,7 +9,7 @@ import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.map
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import org.signal.core.util.Stopwatch import org.signal.core.util.Stopwatch
@@ -140,8 +140,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
} }
val galleryItemsWithSelection = LiveDataUtil.combineLatest( val galleryItemsWithSelection = LiveDataUtil.combineLatest(
Transformations.map(viewModel.state) { it.items }, viewModel.state.map { it.items },
Transformations.map(viewStateLiveData) { it.selectedMedia } viewStateLiveData.map { it.selectedMedia }
) { galleryItems, selectedMedia -> ) { galleryItems, selectedMedia ->
galleryItems.map { galleryItems.map {
if (it is MediaGallerySelectableItem.FileModel) { if (it is MediaGallerySelectableItem.FileModel) {
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.signal.core.ui.BottomSheets
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.CommunicationActions
@@ -41,7 +42,7 @@ class ContactSupportBottomSheetFragment : ComposeBottomSheetDialogFragment() {
.wrapContentSize(Alignment.Center) .wrapContentSize(Alignment.Center)
.padding(16.dp) .padding(16.dp)
) { ) {
Handle() BottomSheets.Handle()
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)) { withStyle(SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)) {
@@ -23,6 +23,8 @@ import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.function.Function; import java.util.function.Function;
import kotlin.jvm.functions.Function1;
public final class LiveDataUtil { public final class LiveDataUtil {
private LiveDataUtil() { private LiveDataUtil() {
@@ -82,7 +84,7 @@ public final class LiveDataUtil {
/** /**
* Performs a map operation on the source observable and then only emits the mapped item if it has changed since the previous emission. * Performs a map operation on the source observable and then only emits the mapped item if it has changed since the previous emission.
*/ */
public static <A, B> LiveData<B> mapDistinct(@NonNull LiveData<A> source, @NonNull androidx.arch.core.util.Function<A, B> mapFunction) { public static <A, B> LiveData<B> mapDistinct(@NonNull LiveData<A> source, @NonNull Function1<A, B> mapFunction) {
return Transformations.distinctUntilChanged(Transformations.map(source, mapFunction)); return Transformations.distinctUntilChanged(Transformations.map(source, mapFunction));
} }
@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.theme.SignalTheme
object BottomSheets {
/**
* Handle for bottom sheets
*/
@Composable
fun Handle(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(width = 48.dp, height = 22.dp)
.padding(vertical = 10.dp)
.clip(RoundedCornerShape(1000.dp))
.background(MaterialTheme.colorScheme.outline)
)
}
}
@Preview
@Composable
private fun HandlePreview() {
SignalTheme(isDarkMode = false) {
BottomSheets.Handle()
}
}
@@ -103,6 +103,7 @@ object Rows {
fun TextRow( fun TextRow(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
icon: ImageVector? = null, icon: ImageVector? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface foregroundTint: Color = MaterialTheme.colorScheme.onSurface
) { ) {
@@ -115,14 +116,15 @@ object Rows {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = foregroundTint tint = foregroundTint,
modifier = iconModifier
) )
Spacer(modifier = Modifier.width(24.dp)) Spacer(modifier = Modifier.width(24.dp))
Text( Text(
text = text, text = text,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f).align(CenterVertically),
color = foregroundTint color = foregroundTint
) )
} }
@@ -137,7 +139,7 @@ object Rows {
} }
@Composable @Composable
private fun defaultPadding(): PaddingValues { fun defaultPadding(): PaddingValues {
return PaddingValues( return PaddingValues(
horizontal = dimensionResource(id = R.dimen.core_ui__gutter), horizontal = dimensionResource(id = R.dimen.core_ui__gutter),
vertical = 16.dp vertical = 16.dp
+1
View File
@@ -27,6 +27,7 @@ dependencyResolutionManagement {
alias('androidx-compose-material3').to('androidx.compose.material3', 'material3').withoutVersion(); alias('androidx-compose-material3').to('androidx.compose.material3', 'material3').withoutVersion();
alias('androidx-compose-ui-tooling-preview').to('androidx.compose.ui', 'ui-tooling-preview').withoutVersion() alias('androidx-compose-ui-tooling-preview').to('androidx.compose.ui', 'ui-tooling-preview').withoutVersion()
alias('androidx-compose-ui-tooling-core').to('androidx.compose.ui', 'ui-tooling').withoutVersion() alias('androidx-compose-ui-tooling-core').to('androidx.compose.ui', 'ui-tooling').withoutVersion()
alias('androidx-compose-runtime-livedata').to('androidx.compose.runtime', 'runtime-livedata').withoutVersion()
alias('androidx-compose-rxjava3').to('androidx.compose.runtime:runtime-rxjava3:1.4.2') alias('androidx-compose-rxjava3').to('androidx.compose.runtime:runtime-rxjava3:1.4.2')
alias('ktlint-twitter-compose').to('com.twitter.compose.rules:ktlint:0.0.26') alias('ktlint-twitter-compose').to('com.twitter.compose.rules:ktlint:0.0.26')
+18
View File
@@ -612,6 +612,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="328c920d5c707fa7ee885cf8c81e4a228d6f9db3dd0d93bf334eead762b6da7f" origin="Generated by Gradle"/> <sha256 value="328c920d5c707fa7ee885cf8c81e4a228d6f9db3dd0d93bf334eead762b6da7f" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="androidx.compose.runtime" name="runtime-livedata" version="1.4.2">
<artifact name="runtime-livedata-1.4.2.aar">
<sha256 value="caa5f34b824f0df6fbf55cc2a55ffff371d365dbc8383a5faa9f2b57b63276cb" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-livedata-1.4.2.module">
<sha256 value="8dc9966778def6caa45eff77be682b08d649d15b23924f6fd16e90406ee60939" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.runtime" name="runtime-rxjava3" version="1.4.2"> <component group="androidx.compose.runtime" name="runtime-rxjava3" version="1.4.2">
<artifact name="runtime-rxjava3-1.4.2.aar"> <artifact name="runtime-rxjava3-1.4.2.aar">
<sha256 value="685877e672f95cf7368ada6e3af07eee107529b6962bf2272b4b1c8d22da7200" origin="Generated by Gradle"/> <sha256 value="685877e672f95cf7368ada6e3af07eee107529b6962bf2272b4b1c8d22da7200" origin="Generated by Gradle"/>
@@ -654,6 +662,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="57031a6ac9b60e5b56792ebf5cde6e16812ff566ed9190cbd188b00b46c13779" origin="Generated by Gradle"/> <sha256 value="57031a6ac9b60e5b56792ebf5cde6e16812ff566ed9190cbd188b00b46c13779" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="androidx.compose.ui" name="ui" version="1.2.1">
<artifact name="ui-1.2.1.module">
<sha256 value="f068eba19a90f039b626afe78ef74f352f124d2834fde52c3bf1a0ea1726b041" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui" version="1.3.1"> <component group="androidx.compose.ui" name="ui" version="1.3.1">
<artifact name="ui-1.3.1.module"> <artifact name="ui-1.3.1.module">
<sha256 value="ba79d53cd1b581ade9c96cbeba9fb7a57606c966130b38daa29013cfe1056e9f" origin="Generated by Gradle"/> <sha256 value="ba79d53cd1b581ade9c96cbeba9fb7a57606c966130b38daa29013cfe1056e9f" origin="Generated by Gradle"/>
@@ -802,6 +815,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="a09871728e5a9d050d2fdcb99a875ef2120dc6deea808f5a6d443dd887e081ca" origin="Generated by Gradle"/> <sha256 value="a09871728e5a9d050d2fdcb99a875ef2120dc6deea808f5a6d443dd887e081ca" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </component>
<component group="androidx.compose.ui" name="ui-util" version="1.2.1">
<artifact name="ui-util-1.2.1.module">
<sha256 value="37fdd696cd9562d8e8390d6ca0389980b51e325fbbb65ffb05f7990742009e59" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-util" version="1.3.3"> <component group="androidx.compose.ui" name="ui-util" version="1.3.3">
<artifact name="ui-util-1.3.3.aar"> <artifact name="ui-util-1.3.3.aar">
<sha256 value="8276a623a1170dbcc259c806bd71f1ac64f2299ef30ada6e5d23d581d08e172c" origin="Generated by Gradle"/> <sha256 value="8276a623a1170dbcc259c806bd71f1ac64f2299ef30ada6e5d23d581d08e172c" origin="Generated by Gradle"/>