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

@ -673,6 +673,11 @@
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.links.details.CallLinkDetailsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.new.NewCallActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysVisible"

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(

View file

@ -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<CallLogRow.CallLink> {
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<CallLink, ContentValues> {
override fun serialize(data: CallLink): ContentValues {
return contentValuesOf(

View file

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

View file

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

View file

@ -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<Badge>,
@get:JvmName("needsPniSignature")
val needsPniSignature: Boolean,
val isHidden: Boolean
val isHidden: Boolean,
val callLinkRoomId: CallLinkRoomId?
) {
fun getDefaultSubscriptionId(): Optional<Int> {

View file

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

View file

@ -136,6 +136,7 @@ public class Recipient {
private final List<Badge> 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<Recipient> a, @NonNull List<Recipient> 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 {

View file

@ -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<Badge> 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();
}

View file

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

View file

@ -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<CallLinkRoomId, String> {
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

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#FF000000"
android:pathData="M10.84 8.42c-0.26 0.25-0.26 0.66 0 0.91 0.25 0.26 0.66 0.26 0.91 0L13.6 7.5c0.7-0.7 1.05-1.62 1.05-2.54 0-1.99-1.61-3.6-3.6-3.6-0.92 0-1.84 0.35-2.54 1.05L6.67 4.25C6.4 4.5 6.4 4.9 6.67 5.16c0.25 0.26 0.66 0.26 0.91 0l1.85-1.84c0.44-0.45 1.03-0.67 1.62-0.67 1.27 0 2.3 1.03 2.3 2.3 0 0.59-0.22 1.18-0.67 1.62l-1.84 1.85Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M10.46 5.54c0.25 0.25 0.25 0.67 0 0.92l-4 4c-0.25 0.25-0.67 0.25-0.92 0s-0.25-0.67 0-0.92l4-4c0.25-0.25 0.67-0.25 0.92 0Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M5.16 6.67c0.26 0.25 0.26 0.66 0 0.91L3.32 9.43c-0.45 0.44-0.67 1.03-0.67 1.62 0 1.27 1.03 2.3 2.3 2.3 0.59 0 1.18-0.22 1.62-0.67l1.85-1.84c0.25-0.26 0.66-0.26 0.91 0 0.26 0.25 0.26 0.66 0 0.91L7.5 13.6c-0.7 0.7-1.62 1.05-2.54 1.05-1.99 0-3.6-1.61-3.6-3.6 0-0.92 0.35-1.84 1.05-2.54l1.85-1.84C4.5 6.4 4.9 6.4 5.16 6.67Z"/>
</vector>

View file

@ -11,6 +11,11 @@
<action
android:id="@+id/action_callLinkDetailsFragment_to_editCallLinkNameDialogFragment"
app:destination="@id/editCallLinkNameDialogFragment" />
<argument
android:name="room_id"
app:argType="org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId"
app:nullable="false" />
</fragment>
<dialog

View file

@ -1667,6 +1667,8 @@
<string name="Recipient_you">You</string>
<!-- Name of recipient representing user\'s \'My Story\' -->
<string name="Recipient_my_story">My Story</string>
<!-- Name of recipient for a call link without a name -->
<string name="Recipient_signal_call">Signal call</string>
<!-- RecipientPreferencesActivity -->
<string name="RecipientPreferenceActivity_block">Block</string>
@ -5927,6 +5929,8 @@
<string name="CallLogAdapter__return">Return</string>
<!-- Call state template when there is more than one call collapsed into a single row. D is a number > 1 and S is a call info string (like Missed) -->
<string name="CallLogAdapter__d_s">(%1$d) %2$s</string>
<!-- Status text on call links -->
<string name="CallLogAdapter__call_link">Call link</string>
<!-- Call Log context menu -->
<!-- Displayed as a context menu item to start a video call -->
@ -6072,6 +6076,12 @@
<string name="CallLinkDetailsFragment__share_link">Share link</string>
<!-- Displayed in a text row, allowing the user to delete the call link -->
<string name="CallLinkDetailsFragment__delete_call_link">Delete call link</string>
<!-- Displayed whenever a name change, revocation, etc, fails. -->
<string name="CallLinkDetailsFragment__couldnt_save_changes">Couldn\'t save changes. Check your network connection and try again.</string>
<!-- Displayed as title in dialog when user attempts to delete the link -->
<string name="CallLinkDetailsFragment__delete_link">Delete link?</string>
<!-- Displayed as body in dialog when user attempts to delete the link -->
<string name="CallLinkDetailsFragment__this_link_will_no_longer_work">This link will no longer work for anyone who as it.</string>
<!-- Button label for the share button in the username link settings -->
<string name="UsernameLinkSettings_share_button_label">Share</string>

View file

@ -153,7 +153,8 @@ object RecipientDatabaseTestUtils {
hasGroupsInCommon,
badges,
needsPniSignature = false,
isHidden = false
isHidden = false,
null
),
participants,
isReleaseChannel,