Allow call links to exist in the calls tab.

This commit is contained in:
Alex Hart
2023-05-22 13:48:41 -03:00
committed by Nicholas
parent 97d95f37cc
commit 987f9b9dba
29 changed files with 657 additions and 117 deletions

View File

@@ -49,7 +49,9 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
/**
@@ -150,18 +152,30 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
private fun setCallName(callName: String) {
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy {
}
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link name")
toastFailure()
}
}, onError = this::handleError)
}
private fun setApproveAllMembers(approveAllMembers: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy {
}
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun toggleApproveAllMembers() {
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy {
}
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun onAddACallNameClicked() {
@@ -172,59 +186,98 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
private fun onJoinClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
}
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
CommunicationActions.startVideoCall(requireActivity(), it.recipient)
dismissAllowingStateLoss()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onDoneClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareViaSignalClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
.build()
)
)
)
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onCopyLinkClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareLinkClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
is EnsureCallLinkCreatedResult.Failure -> {
Log.w(TAG, "Failed to create link: $it")
toastFailure()
}
}
}
}
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
Log.w(TAG, "Failed to create call link: $failure")
toastFailure()
}
private fun onShareViaSignalClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
.build()
)
)
)
}
private fun handleError(throwable: Throwable) {
Log.w(TAG, "Failed to create call link.", throwable)
toastFailure()
}
private fun onCopyLinkClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
}
private fun onShareLinkClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes))
.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 toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
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.CreateCallLinkResult
@@ -24,13 +25,13 @@ class CreateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun ensureCallLinkCreated(credentials: CallLinkCredentials, avatarColor: AvatarColor): Single<EnsureCallLinkCreatedResult> {
val doesCallLinkExistInLocalDatabase = Single.fromCallable {
SignalDatabase.callLinks.callLinkExists(credentials.roomId)
val callLinkRecipientId = Single.fromCallable {
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId)
}
return doesCallLinkExistInLocalDatabase.flatMap { exists ->
if (exists) {
Single.just(EnsureCallLinkCreatedResult.Success)
return callLinkRecipientId.flatMap { recipientId ->
if (recipientId.isPresent) {
Single.just(EnsureCallLinkCreatedResult.Success(Recipient.resolved(recipientId.get())))
} else {
callLinkManager.createCallLink(credentials).map {
when (it) {
@@ -45,7 +46,11 @@ class CreateCallLinkRepository(
)
)
EnsureCallLinkCreatedResult.Success
EnsureCallLinkCreatedResult.Success(
Recipient.resolved(
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
)
)
}
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)

View File

@@ -5,9 +5,10 @@
package org.thoughtcrime.securesms.calls.links.create
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
sealed interface EnsureCallLinkCreatedResult {
object Success : EnsureCallLinkCreatedResult
data class Success(val recipient: Recipient) : EnsureCallLinkCreatedResult
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
}

View File

@@ -5,11 +5,24 @@
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details)
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
companion object {
private const val BUNDLE = "bundle"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
}
}
}

View File

@@ -24,15 +24,20 @@ 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.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
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
@@ -44,6 +49,8 @@ import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.CommunicationActions
import java.time.Instant
/**
@@ -52,7 +59,14 @@ import java.time.Instant
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
private val viewModel: CallLinkDetailsViewModel by viewModels()
companion object {
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
}
private val args: CallLinkDetailsFragmentArgs by navArgs()
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(args.roomId)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -75,11 +89,14 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
}
override fun onNavigationClicked() {
findNavController().popBackStack()
ActivityCompat.finishAfterTransition(requireActivity())
}
override fun onJoinClicked() {
// TODO("Not yet implemented")
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(this, recipientSnapshot)
}
}
override fun onEditNameClicked() {
@@ -104,19 +121,54 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
}
override fun onDeleteClicked() {
lifecycleDisposable += viewModel.revoke().subscribeBy {
}
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
else -> {
Log.w(TAG, "Failed to revoke. $it")
toastFailure()
}
}
}, onError = handleError("onDeleteClicked"))
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(checked).subscribeBy {
}
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).subscribeBy {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
}, onError = handleError("setName"))
}
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()
}
}
private interface CallLinkDetailsCallback {
@@ -125,6 +177,8 @@ private interface CallLinkDetailsCallback {
fun onEditNameClicked()
fun onShareClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
fun onApproveAllMembersChanged(checked: Boolean)
}
@@ -154,9 +208,12 @@ private fun CallLinkDetailsPreview() {
SignalTheme(false) {
CallLinkDetails(
CallLinkDetailsState(
false,
callLink
),
object : CallLinkDetailsCallback {
override fun onDeleteConfirmed() = Unit
override fun onDeleteCanceled() = Unit
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
@@ -215,5 +272,16 @@ private fun CallLinkDetails(
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
)
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}

View File

@@ -6,12 +6,16 @@
package org.thoughtcrime.securesms.calls.links.details
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
@@ -23,11 +27,18 @@ class CallLinkDetailsRepository(
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
.subscribeOn(Schedulers.io())
.subscribeBy {
when (it) {
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, it.callLinkState)
.subscribeBy { result ->
when (result) {
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, result.callLinkState)
is ReadCallLinkResult.Failure -> Unit
}
}
}
fun watchCallLinkRecipient(callLinkRoomId: CallLinkRoomId): Observable<Recipient> {
return Maybe.fromCallable<RecipientId> { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() }
.flatMapObservable { Recipient.observable(it) }
.distinctUntilChanged { a, b -> a.hasSameContent(b) }
.subscribeOn(Schedulers.io())
}
}

View File

@@ -5,8 +5,11 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val callLink: CallLinkTable.CallLink? = null
)

View File

@@ -9,19 +9,22 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLinkDetailsViewModel(
private val callLinkRoomId: CallLinkRoomId,
private val repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
@@ -34,13 +37,19 @@ class CallLinkDetailsViewModel(
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
private val recipientSubject = BehaviorSubject.create<Recipient>()
val recipientSnapshot: Recipient?
get() = recipientSubject.value
init {
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
_state.value = CallLinkDetailsState(
callLink = it
)
_state.value = _state.value.copy(callLink = it)
}
disposables += repository
.watchCallLinkRecipient(callLinkRoomId)
.subscribeBy(onNext = recipientSubject::onNext)
}
override fun onCleared() {
@@ -48,6 +57,10 @@ class CallLinkDetailsViewModel(
disposables.dispose()
}
fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) {
_state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog)
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
@@ -62,4 +75,10 @@ class CallLinkDetailsViewModel(
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}

View File

@@ -62,6 +62,14 @@ class CallLogAdapter(
inflater = CallLogCreateCallLinkItemBinding::inflate
)
)
registerFactory(
CallLinkModel::class.java,
BindingFactory(
creator = { CallLinkModelViewHolder(it, callbacks::onCallLinkClicked, callbacks::onCallLinkLongClicked, callbacks::onStartVideoCallClicked) },
inflater = CallLogAdapterItemBinding::inflate
)
)
}
fun submitCallRows(
@@ -76,6 +84,7 @@ class CallLogAdapter(
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
}
@@ -120,6 +129,44 @@ class CallLogAdapter(
}
}
private class CallLinkModel(
val callLink: CallLogRow.CallLink,
val selectionState: CallLogSelectionState,
val itemCount: Int
) : MappingModel<CallLinkModel> {
companion object {
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
}
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
return callLink.record.roomId == newItem.callLink.record.roomId
}
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
return callLink == newItem.callLink &&
isSelectionStateTheSame(newItem) &&
isItemCountTheSame(newItem)
}
override fun getChangePayload(newItem: CallLinkModel): Any? {
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
CallModel.PAYLOAD_SELECTION_STATE
} else {
null
}
}
private fun isSelectionStateTheSame(newItem: CallLinkModel): Boolean {
return selectionState.contains(callLink.id) == newItem.selectionState.contains(newItem.callLink.id) &&
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
return itemCount == newItem.itemCount
}
}
private class ClearFilterModel : MappingModel<ClearFilterModel> {
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
@@ -131,6 +178,54 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
}
private class CallLinkModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
itemView.setOnClickListener {
onCallLinkClicked(model.callLink)
}
itemView.setOnLongClickListener {
onCallLinkLongClicked(itemView, model.callLink)
}
itemView.isSelected = model.selectionState.contains(model.callLink.id)
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
return
}
binding.callRecipientAvatar.setAvatar(model.callLink.recipient)
val callLinkName = model.callLink.record.state.name.takeIf { it.isNotEmpty() }
?: context.getString(R.string.WebRtcCallView__signal_call)
binding.callRecipientName.text = SearchUtil.getHighlightedSpan(
Locale.getDefault(),
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
callLinkName,
model.callLink.searchQuery,
SearchUtil.MATCH_ALL
)
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
}
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
@@ -235,6 +330,7 @@ class CallLogAdapter(
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -265,6 +361,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.type}")
}
}
@@ -285,6 +382,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.messageType}")
}
}
@@ -324,11 +422,21 @@ class CallLogAdapter(
*/
fun onCallClicked(callLogRow: CallLogRow.Call)
/**
* Invoked when a call link row is clicked
*/
fun onCallLinkClicked(callLogRow: CallLogRow.CallLink)
/**
* Invoked when a call row is long-clicked
*/
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
/**
* Invoked when a call link row is long-clicked
*/
fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean
/**
* Invoked when the clear filter button is pressed
*/

View File

@@ -5,11 +5,13 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
/**
@@ -30,23 +32,42 @@ class CallLogContextMenu(
}
.show(
listOfNotNull(
getVideoCallActionItem(call),
getVideoCallActionItem(call.peer),
getAudioCallActionItem(call),
getGoToChatActionItem(call),
getInfoActionItem(call),
getInfoActionItem(call.peer, (call.id as CallLogRow.Id.Call).children.toLongArray()),
getSelectActionItem(call),
getDeleteActionItem(call)
)
)
}
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
fun show(recyclerView: RecyclerView, anchor: View, callLink: CallLogRow.CallLink) {
recyclerView.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss {
anchor.isSelected = false
recyclerView.suppressLayout(false)
}
.show(
listOfNotNull(
getVideoCallActionItem(callLink.recipient),
getInfoActionItem(callLink.recipient, longArrayOf()),
getSelectActionItem(callLink),
getDeleteActionItem(callLink)
)
)
}
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.symbol_video_24,
title = fragment.getString(R.string.CallContextMenu__video_call)
) {
CommunicationActions.startVideoCall(fragment, call.peer)
CommunicationActions.startVideoCall(fragment, peer)
}
}
@@ -75,20 +96,20 @@ class CallLogContextMenu(
}
}
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
private fun getInfoActionItem(peer: Recipient, messageIds: LongArray): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = when {
call.peer.isCallLink -> throw NotImplementedError("Launch CallLinkDetailsActivity")
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
}
fragment.startActivity(intent)
}
}
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
private fun getSelectActionItem(call: CallLogRow): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
@@ -97,8 +118,8 @@ class CallLogContextMenu(
}
}
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
if (call.record.event == CallTable.Event.ONGOING) {
private fun getDeleteActionItem(call: CallLogRow): ActionItem? {
if (call is CallLogRow.Call && call.record.event == CallTable.Event.ONGOING) {
return null
}
@@ -111,7 +132,7 @@ class CallLogContextMenu(
}
interface Callbacks {
fun startSelection(call: CallLogRow.Call)
fun deleteCall(call: CallLogRow.Call)
fun startSelection(call: CallLogRow)
fun deleteCall(call: CallLogRow)
}
}

View File

@@ -28,6 +28,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
@@ -319,7 +320,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
startActivity(intent)
} else {
throw NotImplementedError("On call link event clicked.")
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
}
}
override fun onCallLinkClicked(callLogRow: CallLogRow.CallLink) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
}
}
@@ -328,6 +337,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
return true
}
override fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean {
callLogContextMenu.show(binding.recycler, itemView, callLinkLogRow)
return true
}
override fun onClearFilterClicked() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
@@ -341,12 +355,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
CommunicationActions.startVideoCall(this, recipient)
}
override fun startSelection(call: CallLogRow.Call) {
override fun startSelection(call: CallLogRow) {
callLogActionMode.start()
viewModel.toggleSelected(call.id)
}
override fun deleteCall(call: CallLogRow.Call) {
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->

View File

@@ -12,28 +12,62 @@ class CallLogPagedDataSource(
private val hasFilter = filter == CallLogFilter.MISSED
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
private var callsCount = 0
private var callEventsCount = 0
private var callLinksCount = 0
override fun size(): Int {
callsCount = repository.getCallsCount(query, filter)
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
callEventsCount = repository.getCallsCount(query, filter)
callLinksCount = repository.getCallLinksCount(query, filter)
return callEventsCount + callLinksCount + hasFilter.toInt() + hasCallLinkRow.toInt()
}
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt()
if (start == 0 && length >= 1 && hasCallLinkRow) {
calls.add(CallLogRow.CreateCallLink)
val callLogRows = mutableListOf<CallLogRow>()
if (length <= 0) {
return callLogRows
}
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
val callLinkStart = if (hasCallLinkRow) 1 else 0
val callEventStart = callLinkStart + callLinksCount
val clearFilterStart = callEventStart + callEventsCount
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
var remaining = length
if (start < callLinkStart) {
callLogRows.add(CallLogRow.CreateCallLink)
remaining -= 1
}
return calls
if (start < callEventStart && remaining > 0) {
val callLinks = repository.getCallLinks(
query,
filter,
start,
remaining
)
callLogRows.addAll(callLinks)
remaining -= callLinks.size
}
if (start < clearFilterStart && remaining > 0) {
val callEvents = repository.getCalls(
query,
filter,
start - callLinksCount,
remaining
)
callLogRows.addAll(callEvents)
remaining -= callEvents.size
}
if (start <= clearFilterStart && remaining > 0) {
callLogRows.add(CallLogRow.ClearFilter)
}
return callLogRows
}
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
@@ -47,5 +81,7 @@ class CallLogPagedDataSource(
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
}
}

View File

@@ -17,6 +17,20 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
return SignalDatabase.calls.getCalls(start, length, query, filter)
}
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
return when (filter) {
CallLogFilter.MISSED -> 0
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinksCount(query)
}
}
override fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
return when (filter) {
CallLogFilter.MISSED -> emptyList()
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length)
}
}
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.messages.markAllCallEventsRead()

View File

@@ -5,9 +5,11 @@
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* A row to be displayed in the call log
@@ -16,6 +18,16 @@ sealed class CallLogRow {
abstract val id: Id
/**
* A call link with no "active" events.
*/
data class CallLink(
val record: CallLinkTable.CallLink,
val recipient: Recipient,
val searchQuery: String?,
override val id: Id = Id.CallLink(record.roomId)
) : CallLogRow()
/**
* An incoming, outgoing, or missed call.
*/
@@ -42,6 +54,7 @@ sealed class CallLogRow {
sealed class Id {
data class Call(val children: Set<Long>) : Id()
data class CallLink(val roomId: CallLinkRoomId) : Id()
object ClearFilter : Id()
object CreateCallLink : Id()
}

View File

@@ -96,7 +96,7 @@ class CallLogViewModel(
}
@MainThread
fun stageCallDeletion(call: CallLogRow.Call) {
fun stageCallDeletion(call: CallLogRow) {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(