Add support for call link epochs.

This commit is contained in:
emir-signal
2025-07-03 15:07:34 -04:00
committed by Alex Hart
parent 5d0f71e02c
commit b42dcece48
34 changed files with 211 additions and 71 deletions

View File

@@ -8,12 +8,14 @@ package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallException
import org.signal.ringrtc.CallLinkEpoch
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.AppDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
/**
@@ -21,12 +23,19 @@ import java.net.URLDecoder
*/
object CallLinks {
private const val ROOT_KEY = "key"
private const val EPOCH = "epoch"
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/call/#key="
private val TAG = Log.tag(CallLinks::class.java)
fun url(linkKeyBytes: ByteArray) = "$HTTPS_LINK_PREFIX${CallLinkRootKey(linkKeyBytes)}"
fun url(rootKeyBytes: ByteArray, epochBytes: ByteArray?): String {
return if (epochBytes == null) {
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
} else {
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}&epoch=${CallLinkEpoch.fromBytes(epochBytes)}"
}
}
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
return Observable.create { emitter ->
@@ -60,8 +69,13 @@ object CallLinks {
return url.split("#").last().startsWith("key=")
}
data class CallLinkParseResult(
val rootKey: CallLinkRootKey,
val epoch: CallLinkEpoch?
)
@JvmStatic
fun parseUrl(url: String): CallLinkRootKey? {
fun parseUrl(url: String): CallLinkParseResult? {
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
Log.w(TAG, "Invalid url prefix.")
return null
@@ -73,18 +87,33 @@ object CallLinks {
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 fragmentQuery = mutableMapOf<String, String?>()
try {
for (part in parts[1].split("&")) {
val kv = part.split("=")
// Make sure we don't have an empty key (i.e. handle the case
// of "a=0&&b=0", for example)
if (kv[0].isEmpty()) {
Log.w(TAG, "Invalid url: $url (empty key)")
return null
}
val key = URLDecoder.decode(kv[0], "utf8")
val value = when (kv.size) {
1 -> null
2 -> URLDecoder.decode(kv[1], "utf8")
else -> {
// Cannot have more than one value per key (i.e. handle the case
// of "a=0&b=0=1=2", for example.
Log.w(TAG, "Invalid url: $url (multiple values)")
return null
}
}
fragmentQuery += key to value
}
val key = URLDecoder.decode(kv[0], "utf8")
val value = URLDecoder.decode(kv[1], "utf8")
key to value
} catch (_: UnsupportedEncodingException) {
Log.w(TAG, "Invalid url: $url")
return null
}
val key = fragmentQuery[ROOT_KEY]
@@ -94,9 +123,13 @@ object CallLinks {
}
return try {
CallLinkRootKey(key)
val epoch = fragmentQuery[EPOCH]?.let { s -> CallLinkEpoch(s) }
CallLinkParseResult(
rootKey = CallLinkRootKey(key),
epoch = epoch
)
} catch (e: CallException) {
Log.w(TAG, "Invalid root key found in fragment query string.")
Log.w(TAG, "Invalid root key or epoch found in fragment query string.")
null
}
}

View File

@@ -53,7 +53,7 @@ import org.signal.core.ui.R as CoreUiR
@Composable
private fun SignalCallRowPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(0, 1, 2, 3), byteArrayOf(5, 6, 7, 8))
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
@@ -97,7 +97,7 @@ fun SignalCallRow(
"https://signal.call.example.com"
} else {
remember(callLink.credentials) {
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes, it.epochBytes) } ?: ""
}
}

View File

@@ -163,7 +163,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
)
)
}
@@ -177,7 +177,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
@@ -192,7 +192,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
is EnsureCallLinkCreatedResult.Success -> {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes))
.setText(CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
.setType(mimeType)
.createChooserIntent()

View File

@@ -31,12 +31,12 @@ class CreateCallLinkViewModel(
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val credentials = CallLinkCredentials.generate()
private val initialCredentials = CallLinkCredentials.generate()
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
roomId = initialCredentials.roomId,
credentials = initialCredentials,
state = SignalCallLinkState(
name = "",
restrictions = Restrictions.ADMIN_APPROVAL,
@@ -48,7 +48,12 @@ class CreateCallLinkViewModel(
)
val callLink: State<CallLinkTable.CallLink> = _callLink
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
val linkKeyBytes: ByteArray
get() = callLink.value.credentials!!.linkKeyBytes
val epochBytes: ByteArray?
get() = callLink.value.credentials!!.epochBytes
private val internalShowAlreadyInACall = MutableStateFlow(false)
val showAlreadyInACall: StateFlow<Boolean> = internalShowAlreadyInACall
@@ -59,7 +64,7 @@ class CreateCallLinkViewModel(
private val disposables = CompositeDisposable()
init {
disposables += CallLinks.watchCallLink(credentials.roomId)
disposables += CallLinks.watchCallLink(initialCredentials.roomId)
.subscribeBy {
_callLink.value = it
}
@@ -75,7 +80,7 @@ class CreateCallLinkViewModel(
}
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
return repository.ensureCallLinkCreated(credentials)
return repository.ensureCallLinkCreated(initialCredentials)
.observeOn(AndroidSchedulers.mainThread())
}
@@ -84,7 +89,7 @@ class CreateCallLinkViewModel(
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
credentials,
callLink.value.credentials!!,
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
@@ -104,7 +109,7 @@ class CreateCallLinkViewModel(
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
credentials,
callLink.value.credentials!!,
callName
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))

View File

@@ -118,7 +118,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot))
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()
@@ -130,7 +130,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
}
override fun onCopyClicked() {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot))
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
@@ -138,7 +138,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot))
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
)
)
}
@@ -230,6 +230,7 @@ private fun CallLinkDetailsPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(

View File

@@ -40,6 +40,9 @@ class CallLinkDetailsViewModel(
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
val epochSnapshot: ByteArray?
get() = state.value.callLink?.credentials?.epochBytes
private val recipientSubject = BehaviorSubject.create<Recipient>()
val recipientSnapshot: Recipient?
get() = recipientSubject.value