Integrate call links create/update/read apis.

This commit is contained in:
Alex Hart
2023-05-19 10:28:29 -03:00
committed by Nicholas Tinsley
parent 4d6d31d624
commit 5a38143987
60 changed files with 1986 additions and 191 deletions

View File

@@ -1,8 +1,83 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.Hex
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import java.net.URLDecoder
/**
* Utility object for call links to try to keep some common logic in one place.
*/
object CallLinks {
fun url(identifier: String) = "https://calls.signal.org/#$identifier"
private const val ROOT_KEY = "key"
private val TAG = Log.tag(CallLinks::class.java)
fun url(linkKeyBytes: ByteArray) = "https://signal.link/call/#key=${Hex.dump(linkKeyBytes)}"
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
return Observable.create { emitter ->
fun refresh() {
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
if (callLink != null) {
emitter.onNext(callLink)
}
}
val observer = DatabaseObserver.Observer {
refresh()
}
ApplicationDependencies.getDatabaseObserver().registerCallLinkObserver(roomId, observer)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
refresh()
}
}
@JvmStatic
fun parseUrl(url: String): CallLinkRootKey? {
val parts = url.split("#")
if (parts.size != 2) {
Log.w(TAG, "Invalid fragment delimiter count in url.")
return null
}
val fragment = parts[1]
val fragmentParts = fragment.split("&")
val fragmentQuery = fragmentParts.associate {
val kv = it.split("=")
if (kv.size != 2) {
Log.w(TAG, "Invalid fragment keypair. Skipping.")
}
val key = URLDecoder.decode(kv[0], "utf8")
val value = URLDecoder.decode(kv[1], "utf8")
key to value
}
val key = fragmentQuery[ROOT_KEY]
if (key == null) {
Log.w(TAG, "Root key not found in fragment query string.")
return null
}
// TODO Parse the key into a byte array
return null
}
}

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import android.app.Dialog

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import androidx.compose.foundation.Image
@@ -30,21 +35,34 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
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.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import java.time.Instant
@Preview
@Composable
private fun SignalCallRowPreview() {
val avatarColor = remember { AvatarColor.random() }
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
name = "Call Name",
identifier = "blahblahblah",
avatarColor = avatarColor,
isApprovalRequired = false
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
expiration = Instant.MAX,
revoked = false
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
@@ -95,10 +113,10 @@ fun SignalCallRow(
.align(CenterVertically)
) {
Text(
text = callLink.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = CallLinks.url(callLink.identifier),
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
/**
* Repository for performing update operations on call links:
* <ul>
* <li>Set name</li>
* <li>Set restrictions</li>
* <li>Revoke link</li>
* </ul>
*
* All of these will delegate to the [SignalCallLinkManager] but will additionally update the database state.
*/
class UpdateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun setCallName(credentials: CallLinkCredentials, name: String): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkName(
credentials = credentials,
name = name
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRestrictions(
credentials = credentials,
restrictions = restrictions
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRevoked(credentials, true)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
return { result ->
if (result is UpdateCallLinkResult.Success) {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
}
}
}
}

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import android.content.ActivityNotFoundException
@@ -28,9 +33,13 @@ import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
@@ -39,6 +48,7 @@ import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
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.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.Util
@@ -47,14 +57,20 @@ import org.thoughtcrime.securesms.util.Util
*/
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
companion object {
private val TAG = Log.tag(CreateCallLinkBottomSheetDialogFragment::class.java)
}
private val viewModel: CreateCallLinkViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override val peekHeightPercentage: Float = 1f
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setCallName(bundle.getString(resultKey)!!)
setCallName(bundle.getString(resultKey)!!)
}
}
}
@@ -94,10 +110,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
)
Rows.ToggleRow(
checked = callLink.isApprovalRequired,
checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
onCheckChanged = this@CreateCallLinkBottomSheetDialogFragment::setApproveAllMembers,
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::toggleApproveAllMembers)
)
Dividers.Default()
@@ -133,54 +149,82 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
}
private fun setCallName(callName: String) {
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy {
}
}
private fun setApproveAllMembers(approveAllMembers: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy {
}
}
private fun toggleApproveAllMembers() {
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy {
}
}
private fun onAddACallNameClicked() {
val snapshot = viewModel.callLink.value
findNavController().navigate(
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.name)
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.state.name)
)
}
private fun onJoinClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
}
}
private fun onDoneClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
when (it) {
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}
}
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
Log.w(TAG, "Failed to create call link: $failure")
}
private fun onShareViaSignalClicked() {
val snapshot = viewModel.callLink.value
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot.identifier)
.build()
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
.build()
)
)
)
)
}
}
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), CallLinks.url(snapshot.identifier))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
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() {
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(snapshot.identifier))
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
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()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
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.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
/**
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
* but will also ensure the database is updated.
*/
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)
}
return doesCallLinkExistInLocalDatabase.flatMap { exists ->
if (exists) {
Single.just(EnsureCallLinkCreatedResult.Success)
} else {
callLinkManager.createCallLink(credentials).map {
when (it) {
is CreateCallLinkResult.Success -> {
SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = it.state,
avatarColor = avatarColor
)
)
EnsureCallLinkCreatedResult.Success
}
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)
}
}
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -1,27 +1,98 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
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 org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
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 java.time.Instant
class CreateCallLinkViewModel : ViewModel() {
class CreateCallLinkViewModel(
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val credentials = CallLinkCredentials.generate()
private val avatarColor = AvatarColor.random()
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.random(), false)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "",
restrictions = Restrictions.NONE,
revoked = false,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
)
val callLink: State<CallLinkTable.CallLink> = _callLink
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
fun setApproveAllMembers(approveAllMembers: Boolean) {
_callLink.value = _callLink.value.copy(isApprovalRequired = approveAllMembers)
private val disposables = CompositeDisposable()
init {
disposables += CallLinks.watchCallLink(credentials.roomId)
.subscribeBy {
_callLink.value = it
}
}
fun toggleApproveAllMembers() {
_callLink.value = _callLink.value.copy(isApprovalRequired = _callLink.value.isApprovalRequired)
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setCallName(callName: String) {
_callLink.value = _callLink.value.copy(name = callName)
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
return repository.ensureCallLinkCreated(credentials, avatarColor)
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
credentials,
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
}
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
credentials,
callName
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
sealed interface EnsureCallLinkCreatedResult {
object Success : EnsureCallLinkCreatedResult
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
}

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.fragment.app.Fragment

View File

@@ -1,7 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -16,19 +24,27 @@ 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.fragment.app.setFragmentResultListener
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.kotlin.subscribeBy
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.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
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 java.time.Instant
/**
* Provides detailed info about a call link and allows the owner of that link
@@ -36,24 +52,24 @@ import org.thoughtcrime.securesms.database.CallLinkTable
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
private val viewModel: CallLinkViewModel by viewModels()
private val viewModel: CallLinkDetailsViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setName(bundle.getString(resultKey)!!)
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun FragmentContent() {
val isLoading by viewModel.isLoading
val callLink by viewModel.callLink
val state by viewModel.state
CallLinkDetails(
isLoading,
callLink,
state,
this
)
}
@@ -67,22 +83,39 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
}
override fun onEditNameClicked() {
val name = viewModel.callLink.value.name
val name = viewModel.nameSnapshot
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
// TODO("Not yet implemented")
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onDeleteClicked() {
// TODO("Not yet implemented")
lifecycleDisposable += viewModel.revoke().subscribeBy {
}
}
override fun onApproveAllMembersChanged(checked: Boolean) {
// TODO("Not yet implemented")
lifecycleDisposable += viewModel.setApproveAllMembers(checked).subscribeBy {
}
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).subscribeBy {
}
}
}
@@ -103,18 +136,26 @@ private fun CallLinkDetailsPreview() {
}
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
name = "Call Name",
identifier = "call-id-1",
isApprovalRequired = false,
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
CallLinkDetails(
false,
callLink,
CallLinkDetailsState(
callLink
),
object : CallLinkDetailsCallback {
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
@@ -129,8 +170,7 @@ private fun CallLinkDetailsPreview() {
@Composable
private fun CallLinkDetails(
isLoading: Boolean,
callLink: CallLinkTable.CallLink,
state: CallLinkDetailsState,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
@@ -138,13 +178,13 @@ private fun CallLinkDetails(
onNavigationClick = callback::onNavigationClicked,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
) { paddingValues ->
if (isLoading) {
if (state.callLink == null) {
return@Settings
}
Column(modifier = Modifier.padding(paddingValues)) {
SignalCallRow(
callLink = callLink,
callLink = state.callLink,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
@@ -155,7 +195,7 @@ private fun CallLinkDetails(
)
Rows.ToggleRow(
checked = callLink.isApprovalRequired,
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
class CallLinkDetailsRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
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)
is ReadCallLinkResult.Failure -> Unit
}
}
}
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import org.thoughtcrime.securesms.database.CallLinkTable
data class CallLinkDetailsState(
val callLink: CallLinkTable.CallLink? = null
)

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
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 org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
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(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
val state: State<CallLinkDetailsState> = _state
val nameSnapshot: String
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
init {
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
_state.value = CallLinkDetailsState(
callLink = it
)
}
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
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)
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
}
fun revoke(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
}
}

View File

@@ -1,22 +0,0 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
class CallLinkViewModel : ViewModel() {
private val isLoadingState: MutableState<Boolean> = mutableStateOf(true)
val isLoading: State<Boolean> = isLoadingState
private val callLinkState: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.A120, false)
)
val callLink: State<CallLinkTable.CallLink> = callLinkState
fun setName(name: String) {
callLinkState.value = callLinkState.value.copy(name = name)
}
}

View File

@@ -153,13 +153,17 @@ class CallLogAdapter(
return
}
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
presentRecipientDetails(model.call.peer)
presentCallInfo(model.call, model.call.date)
presentCallType(model)
}
private fun presentRecipientDetails(recipient: Recipient) {
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), recipient, true)
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
binding.callRecipientName.text = recipient.getDisplayName(context)
}
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
val callState = context.getString(getCallStateStringRes(call.record))
binding.callInfo.text = context.getString(
@@ -321,11 +325,11 @@ class CallLogAdapter(
/**
* Invoked when user presses the audio icon
*/
fun onStartAudioCallClicked(peer: Recipient)
fun onStartAudioCallClicked(recipient: Recipient)
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(peer: Recipient)
fun onStartVideoCallClicked(recipient: Recipient)
}
}

View File

@@ -51,7 +51,7 @@ class CallLogContextMenu(
}
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
if (call.peer.isGroup) {
if (call.peer.isCallLink || call.peer.isGroup) {
return null
}
@@ -63,12 +63,15 @@ class CallLogContextMenu(
}
}
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem? {
return when {
call.peer.isCallLink -> null
else -> ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
}
}
}
@@ -77,7 +80,10 @@ class CallLogContextMenu(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
val intent = when {
call.peer.isCallLink -> throw NotImplementedError("Launch CallLinkDetailsActivity")
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
}
fragment.startActivity(intent)
}
}

View File

@@ -311,9 +311,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
} else if (!callLogRow.peer.isCallLink) {
val intent = ConversationSettingsActivity.forCall(
requireContext(),
callLogRow.peer,
(callLogRow.id as CallLogRow.Id.Call).children.toLongArray()
)
startActivity(intent)
} else {
throw NotImplementedError("On call link event clicked.")
}
}
@@ -327,12 +333,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
override fun onStartAudioCallClicked(peer: Recipient) {
CommunicationActions.startVoiceCall(this, peer)
override fun onStartAudioCallClicked(recipient: Recipient) {
CommunicationActions.startVoiceCall(this, recipient)
}
override fun onStartVideoCallClicked(peer: Recipient) {
CommunicationActions.startVideoCall(this, peer)
override fun onStartVideoCallClicked(recipient: Recipient) {
CommunicationActions.startVideoCall(this, recipient)
}
override fun startSelection(call: CallLogRow.Call) {

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallTable