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

@@ -18,6 +18,7 @@ import com.bumptech.glide.RequestManager
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
@@ -326,7 +327,7 @@ class V2ConversationItemShapeTest {
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) = Unit
override fun onItemClick(item: MultiselectPart?) = Unit

View File

@@ -81,7 +81,8 @@ class CallLinkTableTest {
roomId = CallLinkRoomId.fromBytes(roomId),
credentials = CallLinkCredentials(
linkKeyBytes = roomId,
adminPassBytes = null
adminPassBytes = null,
epochBytes = null
),
state = SignalCallLinkState(),
deletionTimestamp = 0L

View File

@@ -18,6 +18,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -292,7 +293,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}

View File

@@ -11,6 +11,7 @@ import androidx.lifecycle.Observer;
import com.bumptech.glide.RequestManager;
import org.signal.ringrtc.CallLinkEpoch;
import org.signal.ringrtc.CallLinkRootKey;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
@@ -133,7 +134,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull ConversationMessage conversationMessage);
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey);
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey, @Nullable CallLinkEpoch callLinkEpoch);
void onShowSafetyTips(boolean forGroup);
void onReportSpamLearnMoreClicked();
void onMessageRequestAcceptOptionsClicked();

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.nullIfEmpty
import org.signal.ringrtc.CallLinkState
@@ -40,6 +41,7 @@ class CallLinkArchiveExporter(private val cursor: Cursor) : Iterator<ArchiveReci
id = callLink.recipientId.toLong(),
callLink = CallLink(
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
epoch = callLink.credentials.epochBytes?.toByteString() ?: ByteString.EMPTY,
adminKey = callLink.credentials.adminPassBytes?.toByteString()?.nullIfEmpty(),
name = callLink.state.name,
expirationMs = expirationTime.takeIf { it != Long.MAX_VALUE }?.clampToValidBackupRange() ?: 0,

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.importer
import org.signal.core.util.isEmpty
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfEmpty
import org.signal.ringrtc.CallLinkRootKey
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.backup.v2.ArchiveCallLink
@@ -42,7 +43,11 @@ object CallLinkArchiveImporter {
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
credentials = CallLinkCredentials(
callLink.rootKey.toByteArray(),
callLink.epoch.nullIfEmpty()?.toByteArray(),
callLink.adminKey?.toByteArray()
),
state = SignalCallLinkState(
name = callLink.name,
restrictions = callLink.restrictions.toLocal(),

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

View File

@@ -172,11 +172,11 @@ public class LinkPreviewView extends FrameLayout {
spinner.setVisibility(GONE);
noPreview.setVisibility(GONE);
CallLinkRootKey callLinkRootKey = CallLinks.parseUrl(linkPreview.getUrl());
CallLinks.CallLinkParseResult linkParseResult = CallLinks.parseUrl(linkPreview.getUrl());
if (!Util.isEmpty(linkPreview.getTitle())) {
title.setText(linkPreview.getTitle());
title.setVisibility(VISIBLE);
} else if (callLinkRootKey != null) {
} else if (linkParseResult != null) {
title.setText(R.string.Recipient_signal_call);
title.setVisibility(VISIBLE);
} else {
@@ -186,7 +186,7 @@ public class LinkPreviewView extends FrameLayout {
if (showDescription && !Util.isEmpty(linkPreview.getDescription())) {
description.setText(linkPreview.getDescription());
description.setVisibility(VISIBLE);
} else if (callLinkRootKey != null) {
} else if (linkParseResult != null) {
description.setText(R.string.LinkPreviewView__use_this_link_to_join_a_signal_call);
description.setVisibility(VISIBLE);
} else {
@@ -221,14 +221,14 @@ public class LinkPreviewView extends FrameLayout {
thumbnail.get().setImageResource(requestManager, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
thumbnail.get().showSecondaryText(false);
thumbnail.get().setOutlineEnabled(true);
} else if (callLinkRootKey != null) {
} else if (linkParseResult != null) {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageDrawable(
requestManager,
new FallbackAvatarDrawable(
getContext(),
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(linkParseResult.getRootKey().getKeyBytes()))
).circleCrop()
);
thumbnail.get().showSecondaryText(false);

View File

@@ -34,6 +34,8 @@ class ControlsAndInfoViewModel(
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
val epochSnapshot: ByteArray?
get() = state.value.callLink?.credentials?.epochBytes
fun setRecipient(recipient: Recipient) {
if (recipient.isCallLink && callRecipientId != recipient.id) {

View File

@@ -31,7 +31,7 @@ class CallInfoCallbacks(
override fun onShareLinkClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(activity)
.setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot))
.setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot, controlsAndInfoViewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()

View File

@@ -1172,14 +1172,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
//noinspection ConstantConditions
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
CallLinkRootKey callLinkRootKey = CallLinks.parseUrl(linkPreview.getUrl());
if (callLinkRootKey != null) {
CallLinks.CallLinkParseResult linkParseResult = CallLinks.parseUrl(linkPreview.getUrl());
if (linkParseResult != null) {
joinCallLinkStub.setVisibility(View.VISIBLE);
joinCallLinkStub.get().setTextColor(ContextCompat.getColor(context, messageRecord.isOutgoing() ? R.color.signal_light_colorOnPrimary : R.color.signal_colorOnPrimaryContainer));
joinCallLinkStub.get().setBackgroundColor(ContextCompat.getColor(context, messageRecord.isOutgoing() ? R.color.signal_light_colorTransparent2 : R.color.signal_colorOnPrimary));
joinCallLinkStub.get().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onJoinCallLink(callLinkRootKey);
eventListener.onJoinCallLink(linkParseResult.getRootKey(), linkParseResult.getEpoch());
}
});
}

View File

@@ -103,6 +103,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.setActionItemTint
import org.signal.donations.InAppPaymentType
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.GroupMembersDialog
@@ -3365,8 +3366,8 @@ class ConversationFragment :
GroupDescriptionDialog.show(childFragmentManager, groupName, description, shouldLinkifyWebLinks)
}
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey) {
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey, callLinkEpoch) {
YouAreAlreadyInACallSnackbar.show(requireView())
}
}

View File

@@ -21,6 +21,7 @@ import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.calls.log.CallLogRow
@@ -47,6 +48,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
const val TABLE_NAME = "call_link"
const val ID = "_id"
const val ROOT_KEY = "root_key"
const val EPOCH = "epoch"
const val ROOM_ID = "room_id"
const val ADMIN_KEY = "admin_key"
const val NAME = "name"
@@ -61,6 +63,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$ROOT_KEY BLOB,
$EPOCH BLOB,
$ROOM_ID TEXT NOT NULL UNIQUE,
$ADMIN_KEY BLOB,
$NAME TEXT NOT NULL,
@@ -128,6 +131,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
.values(
contentValuesOf(
ROOT_KEY to credentials.linkKeyBytes,
EPOCH to credentials.epochBytes,
ADMIN_KEY to credentials.adminPassBytes
)
)
@@ -188,7 +192,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
fun getOrCreateCallLinkByRootKey(
callLinkRootKey: CallLinkRootKey
callLinkRootKey: CallLinkRootKey,
callLinkEpoch: CallLinkEpoch?
): CallLink {
val roomId = CallLinkRoomId.fromBytes(callLinkRootKey.deriveRoomId())
val callLink = getCallLinkByRoomId(roomId)
@@ -198,6 +203,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
roomId = roomId,
credentials = CallLinkCredentials(
linkKeyBytes = callLinkRootKey.keyBytes,
epochBytes = callLinkEpoch?.bytes,
adminPassBytes = null
),
state = SignalCallLinkState(),
@@ -207,12 +213,26 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
insertCallLink(link)
return getCallLinkByRoomId(roomId)!!
} else {
callLink
if (callLink.credentials?.epoch != callLinkEpoch) {
overwriteEpoch(callLink, callLinkEpoch)
} else {
callLink
}
}
}
private fun overwriteEpoch(callLink: CallLink, callLinkEpoch: CallLinkEpoch?): CallLink {
val modifiedCallLink = callLink.copy(
deletionTimestamp = 0,
credentials = callLink.credentials!!.copy(epochBytes = callLinkEpoch?.bytes)
)
updateCallLinkCredentials(modifiedCallLink.roomId, modifiedCallLink.credentials!!)
return modifiedCallLink
}
fun insertOrUpdateCallLinkByRootKey(
callLinkRootKey: CallLinkRootKey,
callLinkEpoch: CallLinkEpoch?,
adminPassKey: ByteArray?,
deletionTimestamp: Long = 0L,
storageId: StorageId? = null
@@ -227,6 +247,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
roomId = roomId,
credentials = CallLinkCredentials(
linkKeyBytes = callLinkRootKey.keyBytes,
epochBytes = callLinkEpoch?.bytes,
adminPassBytes = adminPassKey
),
state = SignalCallLinkState(),
@@ -253,7 +274,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
writableDatabase.update(TABLE_NAME)
.values(
ADMIN_KEY to adminPassKey,
ROOT_KEY to callLinkRootKey.keyBytes
ROOT_KEY to callLinkRootKey.keyBytes,
EPOCH to callLinkEpoch?.bytes
)
.where("$ROOM_ID = ?", callLink.roomId.serialize())
.run()
@@ -464,6 +486,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
RECIPIENT_ID to data.recipientId.takeIf { it != RecipientId.UNKNOWN }?.toLong(),
ROOM_ID to data.roomId.serialize(),
ROOT_KEY to data.credentials?.linkKeyBytes,
EPOCH to data.credentials?.epochBytes,
ADMIN_KEY to data.credentials?.adminPassBytes
).apply {
putAll(data.state.serialize())
@@ -487,6 +510,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
credentials = data.requireBlob(ROOT_KEY)?.let { linkKey ->
CallLinkCredentials(
linkKeyBytes = linkKey,
epochBytes = data.requireBlob(EPOCH),
adminPassBytes = data.requireBlob(ADMIN_KEY)
)
},

View File

@@ -139,6 +139,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V281_RemoveArchiveT
import org.thoughtcrime.securesms.database.helpers.migration.V282_AddSnippetMessageIdColumnToThreadTable
import org.thoughtcrime.securesms.database.helpers.migration.V283_ViewOnceRemoteDataCleanup
import org.thoughtcrime.securesms.database.helpers.migration.V284_SetPlaceholderGroupFlag
import org.thoughtcrime.securesms.database.helpers.migration.V285_AddEpochToCallLinksTable
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -283,10 +284,11 @@ object SignalDatabaseMigrations {
281 to V281_RemoveArchiveTransferFile,
282 to V282_AddSnippetMessageIdColumnToThreadTable,
283 to V283_ViewOnceRemoteDataCleanup,
284 to V284_SetPlaceholderGroupFlag
284 to V284_SetPlaceholderGroupFlag,
285 to V285_AddEpochToCallLinksTable
)
const val DATABASE_VERSION = 284
const val DATABASE_VERSION = 285
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
@Suppress("ClassName")
object V285_AddEpochToCallLinksTable : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE call_link ADD COLUMN epoch BLOB DEFAULT NULL")
}
}

View File

@@ -48,6 +48,7 @@ class RefreshCallLinkDetailsJob private constructor(
val manager: SignalCallLinkManager = AppDependencies.signalCallManager.callLinkManager
val credentials = CallLinkCredentials(
linkKeyBytes = callLinkUpdate.rootKey!!.toByteArray(),
epochBytes = callLinkUpdate.epoch?.toByteArray(),
adminPassBytes = callLinkUpdate.adminPasskey?.toByteArray()
)

View File

@@ -20,6 +20,7 @@ import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.ringrtc.CallLinkEpoch;
import org.signal.ringrtc.CallLinkRootKey;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.thoughtcrime.securesms.R;
@@ -326,15 +327,20 @@ public class LinkPreviewRepository {
@NonNull String callLinkUrl,
@NonNull Callback callback) {
CallLinkRootKey callLinkRootKey = CallLinks.parseUrl(callLinkUrl);
if (callLinkRootKey == null) {
CallLinks.CallLinkParseResult linkParseResult = CallLinks.parseUrl(callLinkUrl);
if (linkParseResult == null) {
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
return () -> { };
}
CallLinkEpoch epoch = linkParseResult.getEpoch();
byte[] epochBytes = epoch != null ? epoch.getBytes() : null;
Disposable disposable = AppDependencies.getSignalCallManager()
.getCallLinkManager()
.readCallLink(new CallLinkCredentials(callLinkRootKey.getKeyBytes(), null))
.readCallLink(new CallLinkCredentials(linkParseResult.getRootKey().getKeyBytes(),
epochBytes,
null))
.observeOn(Schedulers.io())
.subscribe(
result -> {

View File

@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
@@ -358,7 +359,7 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
Log.w(TAG, "Not yet implemented!", Exception())
}
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
Log.w(TAG, "Not yet implemented!", Exception())
}

View File

@@ -1370,6 +1370,7 @@ object SyncMessageProcessor {
roomId,
CallLinkCredentials(
callLinkUpdate.rootKey!!.toByteArray(),
callLinkUpdate.epoch?.toByteArray(),
callLinkUpdate.adminPasskey?.toByteArray()
)
)
@@ -1381,6 +1382,7 @@ object SyncMessageProcessor {
roomId = roomId,
credentials = CallLinkCredentials(
linkKeyBytes = callLinkRootKey.keyBytes,
epochBytes = callLinkUpdate.epoch?.toByteArray(),
adminPassBytes = callLinkUpdate.adminPasskey?.toByteArray()
),
state = SignalCallLinkState(),

View File

@@ -70,7 +70,7 @@ class CallLinkPreJoinActionProcessor(
serverPublicParams.endorsementPublicKey,
callLinkAuthCredentialPresentation.serialize(),
callLinkRootKey,
null,
callLink.credentials.epoch,
callLink.credentials.adminPassBytes,
ByteArray(0),
AUDIO_LEVELS_INTERVAL,

View File

@@ -25,6 +25,7 @@ import org.signal.libsignal.zkgroup.calllinks.CallLinkSecretParams;
import org.signal.libsignal.zkgroup.groups.GroupIdentifier;
import org.signal.ringrtc.CallException;
import org.signal.ringrtc.CallId;
import org.signal.ringrtc.CallLinkEpoch;
import org.signal.ringrtc.CallLinkRootKey;
import org.signal.ringrtc.CallManager;
import org.signal.ringrtc.GroupCall;
@@ -406,6 +407,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
}
CallLinkRootKey callLinkRootKey = new CallLinkRootKey(callLink.getCredentials().getLinkKeyBytes());
CallLinkEpoch callLinkEpoch = callLink.getCredentials().getEpoch();
GenericServerPublicParams genericServerPublicParams = new GenericServerPublicParams(AppDependencies.getSignalServiceNetworkAccess()
.getConfiguration()
.getGenericServerPublicParams());
@@ -417,7 +419,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
CallLinkSecretParams.deriveFromRootKey(callLinkRootKey.getKeyBytes())
);
callManager.peekCallLinkCall(SignalStore.internal().getGroupCallingServer(), callLinkAuthCredentialPresentation.serialize(), callLinkRootKey, null, peekInfo -> {
callManager.peekCallLinkCall(SignalStore.internal().getGroupCallingServer(), callLinkAuthCredentialPresentation.serialize(), callLinkRootKey, callLinkEpoch, peekInfo -> {
PeekInfo info = peekInfo.getValue();
if (info == null) {
Log.w(TAG, "Failed to get peek info: " + peekInfo.getStatus());

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.service.webrtc.links
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
/**
@@ -15,6 +16,7 @@ import org.signal.ringrtc.CallLinkRootKey
@Parcelize
data class CallLinkCredentials(
val linkKeyBytes: ByteArray,
val epochBytes: ByteArray?,
val adminPassBytes: ByteArray?
) : Parcelable {
@@ -22,6 +24,10 @@ data class CallLinkCredentials(
CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(linkKeyBytes))
}
val epoch: CallLinkEpoch? by lazy {
epochBytes?.let { CallLinkEpoch.fromBytes(it) }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@@ -35,6 +41,12 @@ data class CallLinkCredentials(
} else if (other.adminPassBytes != null) {
return false
}
if (epochBytes != null) {
if (other.epochBytes == null) return false
if (!epochBytes.contentEquals(other.epochBytes)) return false
} else if (other.epochBytes != null) {
return false
}
return true
}
@@ -52,6 +64,7 @@ data class CallLinkCredentials(
fun generate(): CallLinkCredentials {
return CallLinkCredentials(
CallLinkRootKey.generate().keyBytes,
null,
CallLinkRootKey.generateAdminPasskey()
)
}

View File

@@ -120,9 +120,10 @@ class SignalCallLinkManager(
) { result ->
if (result.isSuccess) {
Log.d(TAG, "Successfully created call link.")
val epoch = result.value!!.epoch
emitter.onSuccess(
CreateCallLinkResult.Success(
credentials = CallLinkCredentials(rootKey.keyBytes, adminPassKey),
credentials = CallLinkCredentials(rootKey.keyBytes, epoch?.bytes, adminPassKey),
state = result.value!!.toAppState()
)
)
@@ -142,7 +143,7 @@ class SignalCallLinkManager(
SignalStore.internal.groupCallingServer,
requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes).serialize(),
CallLinkRootKey(credentials.linkKeyBytes),
null
credentials.epoch
) {
if (it.isSuccess) {
emitter.onSuccess(ReadCallLinkResult.Success(it.value!!.toAppState()))
@@ -169,7 +170,7 @@ class SignalCallLinkManager(
SignalStore.internal.groupCallingServer,
credentialPresentation.serialize(),
CallLinkRootKey(credentials.linkKeyBytes),
null,
credentials.epoch,
credentials.adminPassBytes,
name
) { result ->
@@ -197,7 +198,7 @@ class SignalCallLinkManager(
SignalStore.internal.groupCallingServer,
credentialPresentation.serialize(),
CallLinkRootKey(credentials.linkKeyBytes),
null,
credentials.epoch,
credentials.adminPassBytes,
restrictions
) { result ->
@@ -224,7 +225,7 @@ class SignalCallLinkManager(
SignalStore.internal.groupCallingServer,
credentialPresentation.serialize(),
CallLinkRootKey(credentials.linkKeyBytes),
null,
credentials.epoch,
credentials.adminPassBytes
) { result ->
if (result.isSuccess && result.value == true) {

View File

@@ -5,10 +5,12 @@
package org.thoughtcrime.securesms.storage
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.isNotEmpty
import org.signal.core.util.logging.Log
import org.signal.core.util.toOptional
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
@@ -46,8 +48,10 @@ class CallLinkRecordProcessor : DefaultStorageRecordProcessor<SignalCallLinkReco
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
if (callLink != null && callLink.credentials?.adminPassBytes != null) {
val epochBytes = callLink.credentials.epochBytes
return SignalCallLinkRecord.newBuilder(null).apply {
rootKey = callRootKey.keyBytes.toByteString()
epoch = epochBytes?.toByteString() ?: ByteString.EMPTY
adminPasskey = callLink.credentials.adminPassBytes.toByteString()
deletedAtTimestampMs = callLink.deletionTimestamp
}.build().toSignalCallLinkRecord(StorageId.forCallLink(keyGenerator.generate())).toOptional()
@@ -88,8 +92,15 @@ class CallLinkRecordProcessor : DefaultStorageRecordProcessor<SignalCallLinkReco
private fun insertOrUpdateRecord(record: SignalCallLinkRecord) {
val rootKey = CallLinkRootKey(record.proto.rootKey.toByteArray())
val epoch = if (record.proto.epoch.isNotEmpty()) {
CallLinkEpoch.fromBytes(record.proto.epoch.toByteArray())
} else {
null
}
SignalDatabase.callLinks.insertOrUpdateCallLinkByRootKey(
callLinkRootKey = rootKey,
callLinkEpoch = epoch,
adminPassKey = record.proto.adminPasskey.toByteArray(),
deletionTimestamp = record.proto.deletedAtTimestampMs,
storageId = record.id

View File

@@ -27,6 +27,7 @@ import org.signal.core.util.concurrent.JvmRxExtensions;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallLinkEpoch;
import org.signal.ringrtc.CallLinkRootKey;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.calls.links.CallLinks;
@@ -325,8 +326,8 @@ public class CommunicationActions {
return;
}
CallLinkRootKey rootKey = CallLinks.parseUrl(potentialUrl);
if (rootKey == null) {
CallLinks.CallLinkParseResult linkParseResult = CallLinks.parseUrl(potentialUrl);
if (linkParseResult == null) {
Log.w(TAG, "Failed to parse root key from call link");
new MaterialAlertDialogBuilder(activity)
.setTitle(R.string.CommunicationActions_invalid_link)
@@ -336,7 +337,7 @@ public class CommunicationActions {
return;
}
startVideoCall(new ActivityCallContext(activity), rootKey, onUserAlreadyInAnotherCall);
startVideoCall(new ActivityCallContext(activity), linkParseResult.getRootKey(), linkParseResult.getEpoch(), onUserAlreadyInAnotherCall);
}
/**
@@ -345,14 +346,14 @@ public class CommunicationActions {
*
* @param fragment The fragment, which will be used for context and permissions routing.
*/
public static void startVideoCall(@NonNull Fragment fragment, @NonNull CallLinkRootKey rootKey, @NonNull OnUserAlreadyInAnotherCall onUserAlreadyInAnotherCall) {
startVideoCall(new FragmentCallContext(fragment), rootKey, onUserAlreadyInAnotherCall);
public static void startVideoCall(@NonNull Fragment fragment, @NonNull CallLinkRootKey rootKey, @Nullable CallLinkEpoch epoch, @NonNull OnUserAlreadyInAnotherCall onUserAlreadyInAnotherCall) {
startVideoCall(new FragmentCallContext(fragment), rootKey, epoch, onUserAlreadyInAnotherCall);
}
private static void startVideoCall(@NonNull CallContext callContext, @NonNull CallLinkRootKey rootKey, @NonNull OnUserAlreadyInAnotherCall onUserAlreadyInAnotherCall) {
private static void startVideoCall(@NonNull CallContext callContext, @NonNull CallLinkRootKey rootKey, @Nullable CallLinkEpoch epoch, @NonNull OnUserAlreadyInAnotherCall onUserAlreadyInAnotherCall) {
SimpleTask.run(() -> {
CallLinkRoomId roomId = CallLinkRoomId.fromBytes(rootKey.deriveRoomId());
CallLinkTable.CallLink callLink = SignalDatabase.callLinks().getOrCreateCallLinkByRootKey(rootKey);
CallLinkTable.CallLink callLink = SignalDatabase.callLinks().getOrCreateCallLinkByRootKey(rootKey, epoch);
if (callLink.getState().hasBeenRevoked()) {
return Optional.<Recipient>empty();

View File

@@ -344,6 +344,7 @@ message CallLink {
string name = 3;
Restrictions restrictions = 4;
uint64 expirationMs = 5;
bytes epoch = 6; // May be absent/empty for older links
}
message AdHocCall {

View File

@@ -34,6 +34,7 @@ class CallLinkRecordProcessorTest {
private val testSubject = CallLinkRecordProcessor()
private val mockCredentials = CallLinkCredentials(
"root key".toByteArray(),
"abcd".toByteArray(),
"admin pass".toByteArray()
)

View File

@@ -628,6 +628,7 @@ message SyncMessage {
optional bytes rootKey = 1;
optional bytes adminPasskey = 2;
optional Type type = 3; // defaults to UPDATE
optional bytes epoch = 4;
}
message CallLogEvent {

View File

@@ -306,6 +306,7 @@ message CallLinkRecord {
bytes rootKey = 1;
bytes adminPasskey = 2;
uint64 deletedAtTimestampMs = 3;
bytes epoch = 4;
}
message Recipient {