From 987f9b9dbaf90fdf3027784ac75fb8d81670fca9 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 22 May 2023 13:48:41 -0300 Subject: [PATCH] Allow call links to exist in the calls tab. --- app/src/main/AndroidManifest.xml | 5 + ...CreateCallLinkBottomSheetDialogFragment.kt | 141 ++++++++++++------ .../links/create/CreateCallLinkRepository.kt | 17 ++- .../create/EnsureCallLinkCreatedResult.kt | 3 +- .../links/details/CallLinkDetailsActivity.kt | 15 +- .../links/details/CallLinkDetailsFragment.kt | 84 ++++++++++- .../details/CallLinkDetailsRepository.kt | 17 ++- .../links/details/CallLinkDetailsState.kt | 3 + .../links/details/CallLinkDetailsViewModel.kt | 29 +++- .../securesms/calls/log/CallLogAdapter.kt | 108 ++++++++++++++ .../securesms/calls/log/CallLogContextMenu.kt | 45 ++++-- .../securesms/calls/log/CallLogFragment.kt | 20 ++- .../calls/log/CallLogPagedDataSource.kt | 60 ++++++-- .../securesms/calls/log/CallLogRepository.kt | 14 ++ .../securesms/calls/log/CallLogRow.kt | 13 ++ .../securesms/calls/log/CallLogViewModel.kt | 2 +- .../securesms/database/CallLinkTable.kt | 62 +++++++- .../securesms/database/CallTable.kt | 8 +- .../securesms/database/RecipientTable.kt | 11 +- .../database/model/RecipientRecord.kt | 4 +- .../securesms/recipients/LiveRecipient.java | 18 ++- .../securesms/recipients/Recipient.java | 17 ++- .../recipients/RecipientDetails.java | 12 +- .../service/webrtc/IdleActionProcessor.java | 6 +- .../service/webrtc/links/CallLinkRoomId.kt | 27 +++- .../res/drawable/symbol_link_compact_16.xml | 15 ++ .../main/res/navigation/call_link_details.xml | 5 + app/src/main/res/values/strings.xml | 10 ++ .../database/RecipientDatabaseTestUtils.kt | 3 +- 29 files changed, 657 insertions(+), 117 deletions(-) create mode 100644 app/src/main/res/drawable/symbol_link_compact_16.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fe8dba6223..c36fe4aa7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -673,6 +673,11 @@ android:windowSoftInputMode="stateAlwaysVisible" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + { + 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt index fcb56a510d..bbecb7c436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt @@ -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 { - 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt index 1a355b2dc5..58af736916 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt index 4c3d9acda5..d9ecb688f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt @@ -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()) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt index f51064a2e4..2d670aa4d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt index e8f0e34785..33f21e7fc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt @@ -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 { 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 { + return Maybe.fromCallable { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() } + .flatMapObservable { Recipient.observable(it) } + .distinctUntilChanged { a, b -> a.hasSameContent(b) } + .subscribeOn(Schedulers.io()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt index 64f1b9f98c..33d1f887e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt index 24605c540c..9bfdf9f1bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt @@ -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() + 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 { 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 create(modelClass: Class): T { + return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index 90ec1f09ed..b0c8220620 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -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 { + + 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 { 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(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 */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index 1a03e0aa5b..4516144017 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 5458365fdd..ed9b6b4fad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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) { _, _ -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt index 601cd21f51..e6356fdf60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt @@ -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 { - val calls = mutableListOf() - val callLimit = length - hasCallLinkRow.toInt() - - if (start == 0 && length >= 1 && hasCallLinkRow) { - calls.add(CallLogRow.CreateCallLink) + val callLogRows = mutableListOf() + 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 + fun getCallLinksCount(query: String?, filter: CallLogFilter): Int + fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt index 1ee831ddd0..d1d0a26fcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt @@ -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 { + return when (filter) { + CallLogFilter.MISSED -> emptyList() + CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length) + } + } + fun markAllCallEventsRead() { SignalExecutors.BOUNDED_IO.execute { SignalDatabase.messages.markAllCallEventsRead() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt index 545155b158..f56f142c72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt @@ -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) : Id() + data class CallLink(val roomId: CallLinkRoomId) : Id() object ClearFilter : Id() object CreateCallLink : Id() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt index e9012abd05..c09437749d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt @@ -96,7 +96,7 @@ class CallLogViewModel( } @MainThread - fun stageCallDeletion(call: CallLogRow.Call) { + fun stageCallDeletion(call: CallLogRow) { callLogStore.state.stagedDeletion?.commit() callLogStore.update { it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt index dc2ebee949..81aa1e30b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -5,8 +5,10 @@ import android.content.Context import android.database.Cursor import androidx.core.content.contentValuesOf import org.signal.core.util.Serializer +import org.signal.core.util.SqlUtil import org.signal.core.util.insertInto import org.signal.core.util.logging.Log +import org.signal.core.util.readToList import org.signal.core.util.readToSingleInt import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBlob @@ -20,8 +22,10 @@ import org.signal.core.util.select import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.ringrtc.CallLinkState.Restrictions +import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.conversation.colors.AvatarColor 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.CallLinkRoomId @@ -88,7 +92,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database callLink: CallLink ) { writableDatabase.withinTransaction { db -> - val recipientId = SignalDatabase.recipients.getOrInsertFromCallLinkRoomId(callLink.roomId) + val recipientId = SignalDatabase.recipients.getOrInsertFromCallLinkRoomId(callLink.roomId, callLink.avatarColor) db .insertInto(TABLE_NAME) @@ -97,6 +101,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database } ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(callLink.roomId) + ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() } fun updateCallLinkCredentials( @@ -115,6 +120,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database .run() ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(roomId) + ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() } fun updateCallLinkState( @@ -128,6 +134,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database .run() ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(roomId) + ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() } fun callLinkExists( @@ -171,6 +178,59 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database } } + fun getCallLinksCount(query: String?): Int { + return queryCallLinks(query, -1, -1, true).readToSingleInt(0) + } + + fun getCallLinks(query: String?, offset: Int, limit: Int): List { + return queryCallLinks(query, offset, limit, false).readToList { + val callLink = CallLinkDeserializer.deserialize(it) + CallLogRow.CallLink(callLink, Recipient.resolved(callLink.recipientId), query) + } + } + + private fun queryCallLinks(query: String?, offset: Int, limit: Int, asCount: Boolean): Cursor { + //language=sql + val noCallEvent = """ + NOT EXISTS ( + SELECT 1 + FROM ${CallTable.TABLE_NAME} + WHERE ${CallTable.PEER} = $TABLE_NAME.$RECIPIENT_ID + AND ${CallTable.TYPE} = ${CallTable.Type.serialize(CallTable.Type.AD_HOC_CALL)} + AND ${CallTable.EVENT} != ${CallTable.Event.serialize(CallTable.Event.DELETE)} + ) + """.trimIndent() + + val searchFilter = if (!query.isNullOrEmpty()) { + SqlUtil.buildQuery("AND $NAME GLOB ?", SqlUtil.buildCaseInsensitiveGlobPattern(query)) + } else { + null + } + + val limitOffset = if (limit >= 0 && offset >= 0) { + //language=sql + "LIMIT $limit OFFSET $offset" + } else { + "" + } + + val projection = if (asCount) { + "COUNT(*)" + } else { + "*" + } + + //language=sql + val statement = """ + SELECT $projection + FROM $TABLE_NAME + WHERE $noCallEvent AND NOT $REVOKED ${searchFilter?.where ?: ""} + $limitOffset + """.trimIndent() + + return readableDatabase.query(statement, searchFilter?.whereArgs) + } + private object CallLinkSerializer : Serializer { override fun serialize(data: CallLink): ContentValues { return contentValuesOf( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 905e6f2092..2544d868b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -47,14 +47,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl private val TAG = Log.tag(CallTable::class.java) private val TIME_WINDOW = TimeUnit.HOURS.toMillis(4) - private const val TABLE_NAME = "call" + const val TABLE_NAME = "call" private const val ID = "_id" private const val CALL_ID = "call_id" private const val MESSAGE_ID = "message_id" - private const val PEER = "peer" - private const val TYPE = "type" + const val PEER = "peer" + const val TYPE = "type" private const val DIRECTION = "direction" - private const val EVENT = "event" + const val EVENT = "event" private const val TIMESTAMP = "timestamp" private const val RINGER = "ringer" private const val DELETION_TIMESTAMP = "deletion_timestamp" 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 8b0115dd1d..4c7eef4b0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -319,7 +319,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da DISTRIBUTION_LIST_ID, NEEDS_PNI_SIGNATURE, HIDDEN, - REPORTING_TOKEN + REPORTING_TOKEN, + CALL_LINK_ROOM_ID ) private val ID_PROJECTION = arrayOf(ID) @@ -564,14 +565,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da ).recipientId } - fun getOrInsertFromCallLinkRoomId(callLinkRoomId: CallLinkRoomId): RecipientId { + fun getOrInsertFromCallLinkRoomId(callLinkRoomId: CallLinkRoomId, avatarColor: AvatarColor): RecipientId { return getOrInsertByColumn( CALL_LINK_ROOM_ID, callLinkRoomId.serialize(), contentValuesOf( GROUP_TYPE to GroupType.CALL_LINK.id, CALL_LINK_ROOM_ID to callLinkRoomId.serialize(), - PROFILE_SHARING to 1 + PROFILE_SHARING to 1, + AVATAR_COLOR to avatarColor.serialize() ) ).recipientId } @@ -4172,7 +4174,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da hasGroupsInCommon = cursor.requireBoolean(GROUPS_IN_COMMON), badges = parseBadgeList(cursor.requireBlob(BADGES)), needsPniSignature = cursor.requireBoolean(NEEDS_PNI_SIGNATURE), - isHidden = cursor.requireBoolean(HIDDEN) + isHidden = cursor.requireBoolean(HIDDEN), + callLinkRoomId = cursor.requireString(CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index 0f2b58b1f3..24091260fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.wallpaper.ChatWallpaper import org.whispersystems.signalservice.api.push.PNI import org.whispersystems.signalservice.api.push.ServiceId @@ -79,7 +80,8 @@ data class RecipientRecord( val badges: List, @get:JvmName("needsPniSignature") val needsPniSignature: Boolean, - val isHidden: Boolean + val isHidden: Boolean, + val callLinkRoomId: CallLinkRoomId? ) { fun getDefaultSubscriptionId(): Optional { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index 9bb00c0e73..1ac75cdb0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -13,6 +13,7 @@ import com.annimon.stream.Stream; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.CallLinkTable; import org.thoughtcrime.securesms.database.DistributionListTables; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.model.GroupRecord; @@ -194,7 +195,9 @@ public final class LiveRecipient { details = getGroupRecipientDetails(record); } else if (record.getDistributionListId() != null) { details = getDistributionListRecipientDetails(record); - } else { + } else if (record.getCallLinkRoomId() != null) { + details = getCallLinkRecipientDetails(record); + }else { details = RecipientDetails.forIndividual(context, record); } @@ -237,6 +240,19 @@ public final class LiveRecipient { return RecipientDetails.forDistributionList(null, null, record); } + @WorkerThread + private @NonNull RecipientDetails getCallLinkRecipientDetails(@NonNull RecipientRecord record) { + CallLinkTable.CallLink callLink = SignalDatabase.callLinks().getCallLinkByRoomId(Objects.requireNonNull(record.getCallLinkRoomId())); + + if (callLink != null) { + String name = callLink.getState().getName(); + + return RecipientDetails.forCallLink(name, record); + } + + return RecipientDetails.forCallLink(null, record); + } + synchronized void set(@NonNull Recipient recipient) { this.recipient.set(recipient); this.liveData.postValue(recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 8113f6fcd6..a52bd5d798 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -136,6 +136,7 @@ public class Recipient { private final List badges; private final boolean isReleaseNotesRecipient; private final boolean needsPniSignature; + private final CallLinkRoomId callLinkRoomId; /** * Returns a {@link LiveRecipient}, which contains a {@link Recipient} that may or may not be @@ -433,6 +434,7 @@ public class Recipient { this.isReleaseNotesRecipient = false; this.needsPniSignature = false; this.isActiveGroup = false; + this.callLinkRoomId = null; } public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { @@ -489,6 +491,7 @@ public class Recipient { this.isReleaseNotesRecipient = details.isReleaseChannel; this.needsPniSignature = details.needsPniSignature; this.isActiveGroup = details.isActiveGroup; + this.callLinkRoomId = details.callLinkRoomId; } public @NonNull RecipientId getId() { @@ -541,6 +544,8 @@ public class Recipient { return Util.join(names, ", "); } else if (!resolving && isMyStory()) { return context.getString(R.string.Recipient_my_story); + } else if (!resolving && Util.isEmpty(this.groupName) && isCallLink()){ + return context.getString(R.string.Recipient_signal_call); } else { return this.groupName; } @@ -932,6 +937,7 @@ public class Recipient { if (isSelf) return fallbackPhotoProvider.getPhotoForLocalNumber(); else if (isResolving()) return fallbackPhotoProvider.getPhotoForResolvingRecipient(); else if (isDistributionList()) return fallbackPhotoProvider.getPhotoForDistributionList(); + else if (isCallLink()) return fallbackPhotoProvider.getPhotoForCallLink(); else if (isGroupInternal()) return fallbackPhotoProvider.getPhotoForGroup(); else if (isGroup()) return fallbackPhotoProvider.getPhotoForGroup(); else if (!TextUtils.isEmpty(groupName)) return fallbackPhotoProvider.getPhotoForRecipientWithName(groupName, targetSize); @@ -1209,11 +1215,11 @@ public class Recipient { } public boolean isCallLink() { - return false; + return callLinkRoomId != null; } public @NonNull CallLinkRoomId requireCallLinkRoomId() { - throw new UnsupportedOperationException(); + return Objects.requireNonNull(callLinkRoomId); } @Override @@ -1348,7 +1354,8 @@ public class Recipient { Objects.equals(aboutEmoji, other.aboutEmoji) && Objects.equals(extras, other.extras) && hasGroupsInCommon == other.hasGroupsInCommon && - Objects.equals(badges, other.badges); + Objects.equals(badges, other.badges) && + Objects.equals(callLinkRoomId, other.callLinkRoomId); } private static boolean allContentsAreTheSame(@NonNull List a, @NonNull List b) { @@ -1390,6 +1397,10 @@ public class Recipient { public @NonNull FallbackContactPhoto getPhotoForDistributionList() { return new ResourceContactPhoto(R.drawable.symbol_stories_24, R.drawable.symbol_stories_24, R.drawable.symbol_stories_24); } + + public @NonNull FallbackContactPhoto getPhotoForCallLink() { + return new ResourceContactPhoto(R.drawable.symbol_video_24, R.drawable.symbol_video_24, R.drawable.symbol_video_24); + } } private static class MissingAddressError extends AssertionError { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index b1e8fa82c8..022ffa5898 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; @@ -86,6 +87,7 @@ public class RecipientDetails { final List badges; final boolean isReleaseChannel; final boolean needsPniSignature; + final CallLinkRoomId callLinkRoomId; public RecipientDetails(@Nullable String groupName, @Nullable String systemContactName, @@ -150,6 +152,7 @@ public class RecipientDetails { this.badges = record.getBadges(); this.isReleaseChannel = isReleaseChannel; this.needsPniSignature = record.needsPniSignature(); + this.callLinkRoomId = record.getCallLinkRoomId(); } private RecipientDetails() { @@ -203,8 +206,9 @@ public class RecipientDetails { this.hasGroupsInCommon = false; this.badges = Collections.emptyList(); this.isReleaseChannel = false; - this.needsPniSignature = false; - this.isActiveGroup = false; + this.needsPniSignature = false; + this.isActiveGroup = false; + this.callLinkRoomId = null; } public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientRecord settings) { @@ -230,6 +234,10 @@ public class RecipientDetails { return new RecipientDetails(title, null, Optional.empty(), false, false, record.getRegistered(), record, members, false, false); } + public static @NonNull RecipientDetails forCallLink(String name, @NonNull RecipientRecord record) { + return new RecipientDetails(name, null, Optional.empty(), false, false, record.getRegistered(), record, Collections.emptyList(), false, false); + } + public static @NonNull RecipientDetails forUnknown() { return new RecipientDetails(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java index 1437115b46..20a0c957d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java @@ -52,8 +52,8 @@ public class IdleActionProcessor extends WebRtcActionProcessor { Log.i(TAG, "handleOutgoingCall():"); Recipient recipient = Recipient.resolved(remotePeer.getId()); - if (recipient.isGroup()) { - Log.w(TAG, "Aborting attempt to start 1:1 call for group recipient: " + remotePeer.getId()); + if (recipient.isGroup() || recipient.isCallLink()) { + Log.w(TAG, "Aborting attempt to start 1:1 call for group or call link recipient: " + remotePeer.getId()); return currentState; } @@ -65,7 +65,7 @@ public class IdleActionProcessor extends WebRtcActionProcessor { protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) { Log.i(TAG, "handlePreJoinCall():"); - boolean isGroupCall = remotePeer.getRecipient().isPushV2Group(); + boolean isGroupCall = remotePeer.getRecipient().isPushV2Group() || remotePeer.getRecipient().isCallLink(); WebRtcActionProcessor processor = isGroupCall ? new GroupPreJoinActionProcessor(webRtcInteractor) : new PreJoinActionProcessor(webRtcInteractor); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt index 7c86419ff5..98285a7283 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt @@ -8,14 +8,39 @@ package org.thoughtcrime.securesms.service.webrtc.links import android.os.Parcelable import com.google.protobuf.ByteString import kotlinx.parcelize.Parcelize +import org.signal.core.util.Serializer import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.util.Base64 @Parcelize class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcelable { - fun serialize(): String = Base64.encodeBytes(roomId) + fun serialize(): String = DatabaseSerializer.serialize(this) fun encodeForProto(): ByteString = ByteString.copyFrom(roomId) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CallLinkRoomId + + if (!roomId.contentEquals(other.roomId)) return false + + return true + } + + override fun hashCode(): Int { + return roomId.contentHashCode() + } + + object DatabaseSerializer : Serializer { + override fun serialize(data: CallLinkRoomId): String { + return Base64.encodeBytes(data.roomId) + } + + override fun deserialize(data: String): CallLinkRoomId { + return fromBytes(Base64.decode(data)) + } + } companion object { @JvmStatic diff --git a/app/src/main/res/drawable/symbol_link_compact_16.xml b/app/src/main/res/drawable/symbol_link_compact_16.xml new file mode 100644 index 0000000000..87058c67e0 --- /dev/null +++ b/app/src/main/res/drawable/symbol_link_compact_16.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/navigation/call_link_details.xml b/app/src/main/res/navigation/call_link_details.xml index bb3bf2dd3f..b066b0ed3e 100644 --- a/app/src/main/res/navigation/call_link_details.xml +++ b/app/src/main/res/navigation/call_link_details.xml @@ -11,6 +11,11 @@ + + You My Story + + Signal call Block @@ -5927,6 +5929,8 @@ Return (%1$d) %2$s + + Call link @@ -6072,6 +6076,12 @@ Share link Delete call link + + Couldn\'t save changes. Check your network connection and try again. + + Delete link? + + This link will no longer work for anyone who as it. Share diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 82d10f6061..b14bfae074 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -153,7 +153,8 @@ object RecipientDatabaseTestUtils { hasGroupsInCommon, badges, needsPniSignature = false, - isHidden = false + isHidden = false, + null ), participants, isReleaseChannel,