diff --git a/.gitignore b/.gitignore
index e52f59496c..97aa4650e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,4 @@ jni/libspeex/.deps/
pkcs11.password
dev.keystore
maps.key
-local/
+local/
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 92337b988f..97c13b726a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -210,6 +210,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
+ buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
@@ -385,6 +386,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
+ buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 282d8854f0..fe8dba6223 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -599,6 +599,14 @@
+
+
+
+
+
+
+
{
+ 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
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt
index e53b342558..5c6fae32f7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt
index 588bcae810..badd666823 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt
@@ -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
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt
new file mode 100644
index 0000000000..4eb9debe68
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt
@@ -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:
+ *
+ * - Set name
+ * - Set restrictions
+ * - Revoke link
+ *
+ *
+ * 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 {
+ return callLinkManager
+ .updateCallLinkName(
+ credentials = credentials,
+ name = name
+ )
+ .doOnSuccess(updateState(credentials))
+ .subscribeOn(Schedulers.io())
+ }
+
+ fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single {
+ return callLinkManager
+ .updateCallLinkRestrictions(
+ credentials = credentials,
+ restrictions = restrictions
+ )
+ .doOnSuccess(updateState(credentials))
+ .subscribeOn(Schedulers.io())
+ }
+
+ fun revokeCallLink(credentials: CallLinkCredentials): Single {
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt
index 7ef6594936..126e616cfd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt
@@ -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()
+ }
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt
new file mode 100644
index 0000000000..fcb56a510d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt
@@ -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 {
+ 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())
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt
index b0b7df43f3..0b6ddf20f5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt
@@ -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 = 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 = _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 {
+ return repository.ensureCallLinkCreated(credentials, avatarColor)
+ }
+
+ fun setApproveAllMembers(approveAllMembers: Boolean): Single {
+ 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 {
+ return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
+ }
+
+ fun setCallName(callName: String): Single {
+ return commitCallLink()
+ .flatMap {
+ when (it) {
+ is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
+ credentials,
+ callName
+ )
+ is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
+ }
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt
new file mode 100644
index 0000000000..1a355b2dc5
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt
@@ -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
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt
index 4092c53499..4c3d9acda5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt
index 9f6dc2519d..f51064a2e4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt
@@ -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
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt
new file mode 100644
index 0000000000..e8f0e34785
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt
@@ -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 { 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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt
new file mode 100644
index 0000000000..64f1b9f98c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt
@@ -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
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt
new file mode 100644
index 0000000000..24605c540c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt
@@ -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 = mutableStateOf(CallLinkDetailsState())
+ val state: State = _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 {
+ 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 {
+ val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
+ return mutationRepository.setCallName(credentials, name)
+ }
+
+ fun revoke(): Single {
+ val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
+ return mutationRepository.revokeCallLink(credentials)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkViewModel.kt
deleted file mode 100644
index 8f475e4961..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkViewModel.kt
+++ /dev/null
@@ -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 = mutableStateOf(true)
- val isLoading: State = isLoadingState
-
- private val callLinkState: MutableState = mutableStateOf(
- CallLinkTable.CallLink("", "", AvatarColor.A120, false)
- )
- val callLink: State = callLinkState
-
- fun setName(name: String) {
- callLinkState.value = callLinkState.value.copy(name = name)
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt
index ba462ff621..11c7377ee0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt
@@ -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)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt
index 897c7c706e..1a03e0aa5b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt
@@ -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)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt
index 376c3db328..5458365fdd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt
index b7a6c92d82..4c153180e1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt
index 2e721de33c..dc2ebee949 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt
@@ -1,11 +1,34 @@
package org.thoughtcrime.securesms.database
+import android.content.ContentValues
import android.content.Context
+import android.database.Cursor
import androidx.core.content.contentValuesOf
+import org.signal.core.util.Serializer
+import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
+import org.signal.core.util.readToSingleInt
+import org.signal.core.util.readToSingleObject
+import org.signal.core.util.requireBlob
+import org.signal.core.util.requireBoolean
+import org.signal.core.util.requireInt
+import org.signal.core.util.requireLong
+import org.signal.core.util.requireNonNullBlob
+import org.signal.core.util.requireNonNullString
+import org.signal.core.util.requireString
+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.conversation.colors.AvatarColor
+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.CallLinkRoomId
+import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
+import org.thoughtcrime.securesms.util.Base64
+import java.time.Instant
+import java.time.temporal.ChronoUnit
/**
* Table containing ad-hoc call link details
@@ -42,13 +65,168 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE
)
"""
+
+ private fun SignalCallLinkState.serialize(): ContentValues {
+ return contentValuesOf(
+ NAME to name,
+ RESTRICTIONS to restrictions.mapToInt(),
+ EXPIRATION to expiration.toEpochMilli(),
+ REVOKED to revoked
+ )
+ }
+
+ private fun Restrictions.mapToInt(): Int {
+ return when (this) {
+ Restrictions.NONE -> 0
+ Restrictions.ADMIN_APPROVAL -> 1
+ Restrictions.UNKNOWN -> 2
+ }
+ }
+ }
+
+ fun insertCallLink(
+ callLink: CallLink
+ ) {
+ writableDatabase.withinTransaction { db ->
+ val recipientId = SignalDatabase.recipients.getOrInsertFromCallLinkRoomId(callLink.roomId)
+
+ db
+ .insertInto(TABLE_NAME)
+ .values(CallLinkSerializer.serialize(callLink.copy(recipientId = recipientId)))
+ .run()
+ }
+
+ ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(callLink.roomId)
+ }
+
+ fun updateCallLinkCredentials(
+ roomId: CallLinkRoomId,
+ credentials: CallLinkCredentials
+ ) {
+ writableDatabase
+ .update(TABLE_NAME)
+ .values(
+ contentValuesOf(
+ ROOT_KEY to credentials.linkKeyBytes,
+ ADMIN_KEY to credentials.adminPassBytes
+ )
+ )
+ .where("$ROOM_ID = ?", roomId.serialize())
+ .run()
+
+ ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(roomId)
+ }
+
+ fun updateCallLinkState(
+ roomId: CallLinkRoomId,
+ state: SignalCallLinkState
+ ) {
+ writableDatabase
+ .update(TABLE_NAME)
+ .values(state.serialize())
+ .where("$ROOM_ID = ?", roomId.serialize())
+ .run()
+
+ ApplicationDependencies.getDatabaseObserver().notifyCallLinkObservers(roomId)
+ }
+
+ fun callLinkExists(
+ callLinkRoomId: CallLinkRoomId
+ ): Boolean {
+ return writableDatabase
+ .select("COUNT(*)")
+ .from(TABLE_NAME)
+ .where("$ROOM_ID = ?", callLinkRoomId.serialize())
+ .run()
+ .readToSingleInt() > 0
+ }
+
+ fun getCallLinkByRoomId(
+ callLinkRoomId: CallLinkRoomId
+ ): CallLink? {
+ return writableDatabase
+ .select()
+ .from(TABLE_NAME)
+ .where("$ROOM_ID = ?", callLinkRoomId.serialize())
+ .run()
+ .readToSingleObject { CallLinkDeserializer.deserialize(it) }
+ }
+
+ fun getOrCreateCallLinkByRoomId(
+ callLinkRoomId: CallLinkRoomId
+ ): CallLink {
+ val callLink = getCallLinkByRoomId(callLinkRoomId)
+ return if (callLink == null) {
+ val link = CallLink(
+ recipientId = RecipientId.UNKNOWN,
+ roomId = callLinkRoomId,
+ credentials = null,
+ state = SignalCallLinkState(),
+ avatarColor = AvatarColor.random()
+ )
+ insertCallLink(link)
+ return getCallLinkByRoomId(callLinkRoomId)!!
+ } else {
+ callLink
+ }
+ }
+
+ private object CallLinkSerializer : Serializer {
+ override fun serialize(data: CallLink): ContentValues {
+ return contentValuesOf(
+ RECIPIENT_ID to data.recipientId.takeIf { it != RecipientId.UNKNOWN }?.toLong(),
+ ROOM_ID to data.roomId.serialize(),
+ ROOT_KEY to data.credentials?.linkKeyBytes,
+ ADMIN_KEY to data.credentials?.adminPassBytes,
+ AVATAR_COLOR to data.avatarColor.serialize()
+ ).apply {
+ putAll(data.state.serialize())
+ }
+ }
+
+ override fun deserialize(data: ContentValues): CallLink {
+ throw UnsupportedOperationException()
+ }
+ }
+
+ private object CallLinkDeserializer : Serializer {
+ override fun serialize(data: CallLink): Cursor {
+ throw UnsupportedOperationException()
+ }
+
+ override fun deserialize(data: Cursor): CallLink {
+ return CallLink(
+ recipientId = data.requireLong(RECIPIENT_ID).let { if (it > 0) RecipientId.from(it) else RecipientId.UNKNOWN },
+ roomId = CallLinkRoomId.fromBytes(Base64.decode(data.requireNonNullString(ROOM_ID))),
+ credentials = CallLinkCredentials(
+ linkKeyBytes = data.requireNonNullBlob(ROOT_KEY),
+ adminPassBytes = data.requireBlob(ADMIN_KEY)
+ ),
+ state = SignalCallLinkState(
+ name = data.requireNonNullString(NAME),
+ restrictions = data.requireInt(RESTRICTIONS).mapToRestrictions(),
+ revoked = data.requireBoolean(REVOKED),
+ expiration = Instant.ofEpochMilli(data.requireLong(EXPIRATION)).truncatedTo(ChronoUnit.DAYS)
+ ),
+ avatarColor = AvatarColor.deserialize(data.requireString(AVATAR_COLOR))
+ )
+ }
+
+ private fun Int.mapToRestrictions(): Restrictions {
+ return when (this) {
+ 0 -> Restrictions.NONE
+ 1 -> Restrictions.ADMIN_APPROVAL
+ else -> Restrictions.UNKNOWN
+ }
+ }
}
data class CallLink(
- val name: String,
- val identifier: String,
- val avatarColor: AvatarColor,
- val isApprovalRequired: Boolean
+ val recipientId: RecipientId,
+ val roomId: CallLinkRoomId,
+ val credentials: CallLinkCredentials?,
+ val state: SignalCallLinkState,
+ val avatarColor: AvatarColor
)
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt
index 52c87c2dc5..faa797e7a5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt
@@ -244,10 +244,11 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
// region Group / Ad-Hoc Calling
-
fun deleteGroupCall(call: Call) {
checkIsGroupOrAdHocCall(call)
+ val filter: SqlUtil.Query = getCallSelectionQuery(call.callId, call.peer)
+
writableDatabase.withinTransaction { db ->
db
.update(TABLE_NAME)
@@ -255,7 +256,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
EVENT to Event.serialize(Event.DELETE),
DELETION_TIMESTAMP to System.currentTimeMillis()
)
- .where("$CALL_ID = ? AND $PEER = ?", call.callId, call.peer)
+ .where(filter.where, filter.whereArgs)
.run()
if (call.messageId != null) {
@@ -274,7 +275,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
direction: Direction,
timestamp: Long
) {
- val type = Type.GROUP_CALL
+ val recipient = Recipient.resolved(recipientId)
+ val type = if (recipient.isCallLink) Type.AD_HOC_CALL else Type.GROUP_CALL
writableDatabase
.insertInto(TABLE_NAME)
@@ -322,7 +324,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
direction: Direction,
timestamp: Long
) {
- val type = Type.GROUP_CALL
+ val recipient = Recipient.resolved(recipientId)
+ val type = if (recipient.isCallLink) Type.AD_HOC_CALL else Type.GROUP_CALL
val event = if (direction == Direction.OUTGOING) Event.OUTGOING_RING else Event.JOINED
val ringer = if (direction == Direction.OUTGOING) Recipient.self().id.toLong() else null
@@ -951,7 +954,6 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List {
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { cursor ->
val call = Call.deserialize(cursor)
- val recipient = Recipient.resolved(call.peer)
val groupCallDetails = GroupCallUpdateDetailsUtil.parse(cursor.requireString(MessageTable.BODY))
val children = cursor.requireNonNullString("children")
@@ -969,8 +971,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
CallLogRow.Call(
record = call,
- peer = recipient,
date = call.timestamp,
+ peer = Recipient.resolved(call.peer),
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails),
children = actualChildren.toSet()
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
index ac8344b1ce..02599ce569 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
@@ -10,6 +10,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
+import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
import java.util.Collection;
@@ -46,27 +47,28 @@ public class DatabaseObserver {
private static final String KEY_CONVERSATION_DELETES = "ConversationDeletes";
private static final String KEY_CALL_UPDATES = "CallUpdates";
+ private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
private final Application application;
private final Executor executor;
- private final Set conversationListObservers;
- private final Map> conversationObservers;
- private final Map> verboseConversationObservers;
- private final Map> conversationDeleteObservers;
- private final Map> paymentObservers;
- private final Map> scheduledMessageObservers;
- private final Set allPaymentsObservers;
- private final Set chatColorsObservers;
- private final Set stickerObservers;
- private final Set stickerPackObservers;
- private final Set attachmentObservers;
- private final Set messageUpdateObservers;
- private final Map> messageInsertObservers;
- private final Set notificationProfileObservers;
- private final Map> storyObservers;
-
- private final Set callUpdateObservers;
+ private final Set conversationListObservers;
+ private final Map> conversationObservers;
+ private final Map> verboseConversationObservers;
+ private final Map> conversationDeleteObservers;
+ private final Map> paymentObservers;
+ private final Map> scheduledMessageObservers;
+ private final Set allPaymentsObservers;
+ private final Set chatColorsObservers;
+ private final Set stickerObservers;
+ private final Set stickerPackObservers;
+ private final Set attachmentObservers;
+ private final Set messageUpdateObservers;
+ private final Map> messageInsertObservers;
+ private final Set notificationProfileObservers;
+ private final Map> storyObservers;
+ private final Set callUpdateObservers;
+ private final Map> callLinkObservers;
public DatabaseObserver(Application application) {
this.application = application;
@@ -87,6 +89,7 @@ public class DatabaseObserver {
this.storyObservers = new HashMap<>();
this.scheduledMessageObservers = new HashMap<>();
this.callUpdateObservers = new HashSet<>();
+ this.callLinkObservers = new HashMap<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@@ -186,6 +189,12 @@ public class DatabaseObserver {
executor.execute(() -> callUpdateObservers.add(observer));
}
+ public void registerCallLinkObserver(@NonNull CallLinkRoomId callLinkRoomId, @NonNull Observer observer) {
+ executor.execute(() -> {
+ registerMapped(callLinkObservers, callLinkRoomId, observer);
+ });
+ }
+
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@@ -201,6 +210,7 @@ public class DatabaseObserver {
unregisterMapped(scheduledMessageObservers, listener);
unregisterMapped(conversationDeleteObservers, listener);
callUpdateObservers.remove(listener);
+ unregisterMapped(callLinkObservers, listener);
});
}
@@ -342,6 +352,10 @@ public class DatabaseObserver {
runPostSuccessfulTransaction(KEY_CALL_UPDATES, () -> notifySet(callUpdateObservers));
}
+ public void notifyCallLinkObservers(@NonNull CallLinkRoomId callLinkRoomId) {
+ runPostSuccessfulTransaction(KEY_CALL_LINK_UPDATES, () -> notifyMapped(callLinkObservers, callLinkRoomId));
+ }
+
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
executor.execute(runnable);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
index f117e1093c..9b04c06ac0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt
@@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
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.storage.StorageRecordUpdate
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.storage.StorageSyncModels
@@ -421,6 +422,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return getByColumn(SERVICE_ID, serviceId.toString())
}
+ fun getByCallLinkRoomId(callLinkRoomId: CallLinkRoomId): Optional {
+ return getByColumn(CALL_LINK_ROOM_ID, callLinkRoomId.serialize())
+ }
+
/**
* Will return a recipient matching the PNI, but only in the explicit [PNI_COLUMN]. This should only be checked in conjunction with [getByServiceId] as a way
* to avoid creating a recipient we already merged.
@@ -559,6 +564,18 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
).recipientId
}
+ fun getOrInsertFromCallLinkRoomId(callLinkRoomId: CallLinkRoomId): 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
+ )
+ ).recipientId
+ }
+
fun getDistributionListRecipientIds(): List {
val recipientIds = mutableListOf()
readableDatabase.query(TABLE_NAME, arrayOf(ID), "$DISTRIBUTION_LIST_ID is not NULL", null, null, null, null).use { cursor ->
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt
index 0b1bd23029..da5e3618e7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt
@@ -74,6 +74,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val pendingPniSignatureMessageTable: PendingPniSignatureMessageTable = PendingPniSignatureMessageTable(context, this)
val callTable: CallTable = CallTable(context, this)
val kyberPreKeyTable: KyberPreKeyTable = KyberPreKeyTable(context, this)
+ val callLinkTable: CallLinkTable = CallLinkTable(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
@@ -536,5 +537,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
@get:JvmName("calls")
val calls: CallTable
get() = instance!!.callTable
+
+ @get:JvmStatic
+ @get:JvmName("callLinks")
+ val callLinks: CallLinkTable
+ get() = instance!!.callLinkTable
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
index f0967b6825..5b831067c7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
@@ -54,6 +54,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.TrustStore;
+import org.whispersystems.signalservice.api.services.CallLinksService;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
@@ -128,6 +129,7 @@ public class ApplicationDependencies {
private static volatile SimpleExoPlayerPool exoPlayerPool;
private static volatile AudioManagerCompat audioManagerCompat;
private static volatile DonationsService donationsService;
+ private static volatile CallLinksService callLinksService;
private static volatile ProfileService profileService;
private static volatile DeadlockDetector deadlockDetector;
private static volatile ClientZkReceiptOperations clientZkReceiptOperations;
@@ -652,6 +654,17 @@ public class ApplicationDependencies {
return donationsService;
}
+ public static @NonNull CallLinksService getCallLinksService() {
+ if (callLinksService == null) {
+ synchronized (LOCK) {
+ if (callLinksService == null) {
+ callLinksService = provider.provideCallLinksService(getSignalServiceNetworkAccess().getConfiguration(), getGroupsV2Operations());
+ }
+ }
+ }
+ return callLinksService;
+ }
+
public static @NonNull ProfileService getProfileService() {
if (profileService == null) {
synchronized (LOCK) {
@@ -721,6 +734,7 @@ public class ApplicationDependencies {
@NonNull SimpleExoPlayerPool provideExoPlayerPool();
@NonNull AudioManagerCompat provideAndroidCallAudioManager();
@NonNull DonationsService provideDonationsService(@NonNull SignalServiceConfiguration signalServiceConfiguration, @NonNull GroupsV2Operations groupsV2Operations);
+ @NonNull CallLinksService provideCallLinksService(@NonNull SignalServiceConfiguration signalServiceConfiguration, @NonNull GroupsV2Operations groupsV2Operations);
@NonNull ProfileService provideProfileService(@NonNull ClientZkProfileOperations profileOperations, @NonNull SignalServiceMessageReceiver signalServiceMessageReceiver, @NonNull SignalWebSocket signalWebSocket);
@NonNull DeadlockDetector provideDeadlockDetector();
@NonNull ClientZkReceiptOperations provideClientZkReceiptOperations(@NonNull SignalServiceConfiguration signalServiceConfiguration);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
index accda97940..6a6b908f68 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
@@ -87,6 +87,7 @@ import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.PNI;
+import org.whispersystems.signalservice.api.services.CallLinksService;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
@@ -372,6 +373,15 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
FeatureFlags.okHttpAutomaticRetry());
}
+ @Override
+ public @NonNull CallLinksService provideCallLinksService(@NonNull SignalServiceConfiguration signalServiceConfiguration, @NonNull GroupsV2Operations groupsV2Operations) {
+ return new CallLinksService(signalServiceConfiguration,
+ new DynamicCredentialsProvider(),
+ BuildConfig.SIGNAL_AGENT,
+ groupsV2Operations,
+ FeatureFlags.okHttpAutomaticRetry());
+ }
+
@Override
public @NonNull ProfileService provideProfileService(@NonNull ClientZkProfileOperations clientZkProfileOperations,
@NonNull SignalServiceMessageReceiver receiver,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java
index c59c82459d..075ded4436 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java
@@ -3,15 +3,26 @@ package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.zkgroup.GenericServerPublicParams;
+import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredential;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialPresentation;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkSecretParams;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
+import org.thoughtcrime.securesms.ApplicationContext;
+import org.thoughtcrime.securesms.BuildConfig;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
import org.whispersystems.signalservice.api.push.ServiceIds;
import java.io.IOException;
+import java.time.Instant;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -33,10 +44,10 @@ public class GroupsV2Authorization {
{
final long today = currentDaySeconds();
- Map credentials = authCache.read();
+ GroupsV2Api.CredentialResponseMaps credentials = authCache.read();
try {
- return getAuthorization(serviceIds, groupSecretParams, credentials, today);
+ return getAuthorization(serviceIds, groupSecretParams, credentials.getAuthCredentialWithPniResponseHashMap(), today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.i(TAG, "Auth out of date, will update auth and try again");
authCache.clear();
@@ -50,13 +61,54 @@ public class GroupsV2Authorization {
authCache.write(credentials);
try {
- return getAuthorization(serviceIds, groupSecretParams, credentials, today);
+ return getAuthorization(serviceIds, groupSecretParams, credentials.getAuthCredentialWithPniResponseHashMap(), today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.w(TAG, "The credentials returned did not include the day requested");
throw new IOException("Failed to get credentials");
}
}
+ public CallLinkAuthCredentialPresentation getCallLinkAuthorizationForToday(@NonNull GenericServerPublicParams genericServerPublicParams,
+ @NonNull CallLinkSecretParams callLinkSecretParams)
+ throws IOException, VerificationFailedException
+ {
+ final long today = currentDaySeconds();
+
+ GroupsV2Api.CredentialResponseMaps credentials = authCache.read();
+
+ try {
+ return getCallLinkAuthCredentialPresentation(
+ genericServerPublicParams,
+ callLinkSecretParams,
+ credentials.getCallLinkAuthCredentialResponseHashMap(),
+ today
+ );
+ } catch (NoCredentialForRedemptionTimeException e) {
+ Log.i(TAG, "Auth out of date, will update auth and try again");
+ authCache.clear();
+ } catch (VerificationFailedException e) {
+ Log.w(TAG, "Verification failed, will update auth and try again", e);
+ authCache.clear();
+ }
+
+ Log.i(TAG, "Getting new auth credential responses");
+ credentials = groupsV2Api.getCredentials(today);
+ authCache.write(credentials);
+
+ try {
+ return getCallLinkAuthCredentialPresentation(
+ genericServerPublicParams,
+ callLinkSecretParams,
+ credentials.getCallLinkAuthCredentialResponseHashMap(),
+ today
+ );
+ } catch (NoCredentialForRedemptionTimeException e) {
+ Log.w(TAG, "The credentials returned did not include the day requested");
+ throw new IOException("Failed to get credentials");
+ }
+ }
+
+
public void clear() {
authCache.clear();
}
@@ -65,6 +117,32 @@ public class GroupsV2Authorization {
return TimeUnit.DAYS.toSeconds(TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()));
}
+ private CallLinkAuthCredentialPresentation getCallLinkAuthCredentialPresentation(GenericServerPublicParams genericServerPublicParams,
+ CallLinkSecretParams callLinkSecretParams,
+ Map credentials,
+ long todaySeconds)
+ throws NoCredentialForRedemptionTimeException, VerificationFailedException
+ {
+ CallLinkAuthCredentialResponse authCredentialResponse = credentials.get(todaySeconds);
+
+ if (authCredentialResponse == null) {
+ throw new NoCredentialForRedemptionTimeException();
+ }
+
+ CallLinkAuthCredential credential = authCredentialResponse.receive(
+ Recipient.self().requireServiceId().uuid(),
+ Instant.ofEpochSecond(todaySeconds),
+ genericServerPublicParams
+ );
+
+ return credential.present(
+ Recipient.self().requireServiceId().uuid(),
+ Instant.ofEpochSecond(todaySeconds),
+ genericServerPublicParams,
+ callLinkSecretParams
+ );
+ }
+
private GroupsV2AuthorizationString getAuthorization(ServiceIds serviceIds,
GroupSecretParams groupSecretParams,
Map credentials,
@@ -84,8 +162,8 @@ public class GroupsV2Authorization {
void clear();
- @NonNull Map read();
+ @NonNull GroupsV2Api.CredentialResponseMaps read();
- void write(@NonNull Map values);
+ void write(@NonNull GroupsV2Api.CredentialResponseMaps values);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java
index bce7c614da..79bde0e6bf 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
+import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import java.util.Collections;
import java.util.HashMap;
@@ -10,8 +11,8 @@ import java.util.Map;
public final class GroupsV2AuthorizationMemoryValueCache implements GroupsV2Authorization.ValueCache {
- private final GroupsV2Authorization.ValueCache inner;
- private Map values;
+ private final GroupsV2Authorization.ValueCache inner;
+ private GroupsV2Api.CredentialResponseMaps values;
public GroupsV2AuthorizationMemoryValueCache(@NonNull GroupsV2Authorization.ValueCache inner) {
this.inner = inner;
@@ -24,8 +25,8 @@ public final class GroupsV2AuthorizationMemoryValueCache implements GroupsV2Auth
}
@Override
- public @NonNull synchronized Map read() {
- Map map = values;
+ public @NonNull synchronized GroupsV2Api.CredentialResponseMaps read() {
+ GroupsV2Api.CredentialResponseMaps map = values;
if (map == null) {
map = inner.read();
@@ -36,8 +37,8 @@ public final class GroupsV2AuthorizationMemoryValueCache implements GroupsV2Auth
}
@Override
- public synchronized void write(@NonNull Map values) {
+ public synchronized void write(@NonNull GroupsV2Api.CredentialResponseMaps values) {
inner.write(values);
- this.values = Collections.unmodifiableMap(new HashMap<>(values));
+ this.values = values.createUnmodifiableCopy();
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt
index f8eae4e1c4..4425b4d5e6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt
@@ -138,7 +138,7 @@ class CallSyncEventJob private constructor(
}
}
- private fun CallSyncEventJobRecord.deserializeRecipientId(): RecipientId = RecipientId.from(recipientId!!)
+ private fun CallSyncEventJobRecord.deserializeRecipientId(): RecipientId = RecipientId.from(recipientId)
private fun CallSyncEventJobRecord.deserializeDirection(): CallTable.Direction = CallTable.Direction.deserialize(direction)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 6a1af870e3..14bfcffa20 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -132,6 +132,7 @@ public final class JobManagerFactories {
put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory());
put(MmsSendJob.KEY, new MmsSendJob.Factory());
put(MultiDeviceBlockedUpdateJob.KEY, new MultiDeviceBlockedUpdateJob.Factory());
+ put(MultiDeviceCallLinkSyncJob.KEY, new MultiDeviceCallLinkSyncJob.Factory());
put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory());
put(MultiDeviceContactSyncJob.KEY, new MultiDeviceContactSyncJob.Factory());
put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory());
@@ -173,6 +174,7 @@ public final class JobManagerFactories {
put(ReactionSendJob.KEY, new ReactionSendJob.Factory());
put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory());
put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory());
+ put(RefreshCallLinkDetailsJob.KEY, new RefreshCallLinkDetailsJob.Factory());
put(RefreshKbsCredentialsJob.KEY, new RefreshKbsCredentialsJob.Factory());
put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory());
put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceCallLinkSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceCallLinkSyncJob.kt
new file mode 100644
index 0000000000..488907700c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceCallLinkSyncJob.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.jobs
+
+import com.google.protobuf.ByteString
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.jobmanager.Job
+import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
+import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
+import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
+import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLinkUpdate
+import java.util.Optional
+import kotlin.time.Duration.Companion.days
+
+/**
+ * Sends a sync message to linked devices when a new call link is created locally.
+ */
+class MultiDeviceCallLinkSyncJob private constructor(
+ parameters: Parameters,
+ private val callLinkUpdate: CallLinkUpdate
+) : BaseJob(parameters) {
+
+ constructor(credentials: CallLinkCredentials) : this(
+ Parameters.Builder()
+ .setQueue("__MULTI_DEVICE_CALL_LINK_UPDATE_JOB__")
+ .addConstraint(NetworkConstraint.KEY)
+ .setLifespan(1.days.inWholeMilliseconds)
+ .setMaxAttempts(Parameters.UNLIMITED)
+ .build(),
+ CallLinkUpdate.newBuilder()
+ .setRootKey(ByteString.copyFrom(credentials.linkKeyBytes))
+ .setAdminPassKey(ByteString.copyFrom(credentials.adminPassBytes!!))
+ .build()
+ )
+
+ companion object {
+ const val KEY = "MultiDeviceCallLinkSyncJob"
+
+ private val TAG = Log.tag(MultiDeviceCallLinkSyncJob::class.java)
+ }
+
+ override fun serialize(): ByteArray {
+ return callLinkUpdate.toByteArray()
+ }
+
+ override fun getFactoryKey(): String = KEY
+
+ override fun onFailure() = Unit
+
+ override fun onRun() {
+ val syncMessage = SignalServiceSyncMessage.forCallLinkUpdate(callLinkUpdate)
+
+ try {
+ ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(syncMessage, Optional.empty())
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to send call link update message.", e)
+ throw e
+ }
+ }
+
+ override fun onShouldRetry(exception: Exception): Boolean {
+ return when (exception) {
+ is PushNetworkException -> true
+ else -> false
+ }
+ }
+
+ class Factory : Job.Factory {
+ override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceCallLinkSyncJob {
+ val data = CallLinkUpdate.parseFrom(serializedData)
+ return MultiDeviceCallLinkSyncJob(parameters, data)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshCallLinkDetailsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshCallLinkDetailsJob.kt
new file mode 100644
index 0000000000..cc42d8fbed
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshCallLinkDetailsJob.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.jobs
+
+import org.signal.core.util.concurrent.safeBlockingGet
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.jobmanager.Job
+import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
+import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
+import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
+import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLinkUpdate
+import java.util.concurrent.TimeUnit
+
+/**
+ * Requests the latest call link state from the call service.
+ */
+class RefreshCallLinkDetailsJob private constructor(
+ parameters: Parameters,
+ private val callLinkUpdate: CallLinkUpdate
+) : BaseJob(parameters) {
+
+ constructor(callLinkUpdate: CallLinkUpdate) : this(
+ Parameters.Builder()
+ .addConstraint(NetworkConstraint.KEY)
+ .setQueue("__RefreshCallLinkDetailsJob__")
+ .setLifespan(TimeUnit.DAYS.toMillis(1))
+ .setMaxAttempts(Parameters.UNLIMITED)
+ .build(),
+ callLinkUpdate
+ )
+
+ companion object {
+ const val KEY = "RefreshCallLinkDetailsJob"
+ }
+
+ override fun serialize(): ByteArray = callLinkUpdate.toByteArray()
+
+ override fun getFactoryKey(): String = KEY
+
+ override fun onFailure() = Unit
+
+ override fun onRun() {
+ val manager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
+ val credentials = CallLinkCredentials(
+ linkKeyBytes = callLinkUpdate.rootKey.toByteArray(),
+ adminPassBytes = callLinkUpdate.adminPassKey?.toByteArray()
+ )
+
+ when (val result = manager.readCallLink(credentials).safeBlockingGet()) {
+ is ReadCallLinkResult.Success -> {
+ SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.callLinkState)
+ }
+ else -> Unit
+ }
+ }
+
+ override fun onShouldRetry(e: Exception): Boolean = false
+
+ class Factory : Job.Factory {
+ override fun create(parameters: Parameters, serializedData: ByteArray?): RefreshCallLinkDetailsJob {
+ val callLinkUpdate = CallLinkUpdate.parseFrom(serializedData)
+ return RefreshCallLinkDetailsJob(parameters, callLinkUpdate)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java
index f9d7ddfe91..9f92177cef 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java
@@ -8,23 +8,29 @@ import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
+import org.signal.libsignal.zkgroup.internal.ByteArray;
import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponse;
import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponses;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
+import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
+import java.util.function.Function;
public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Authorization.ValueCache {
private static final String TAG = Log.tag(GroupsV2AuthorizationSignalStoreCache.class);
- private static final String ACI_PNI_PREFIX = "gv2:auth_token_cache";
- private static final int ACI_PNI_VERSION = 3;
+ private static final String CALL_LINK_AUTH_PREFIX = "call_link_auth:";
+ private static final String ACI_PNI_PREFIX = "gv2:auth_token_cache";
+ private static final int ACI_PNI_VERSION = 3;
private final String key;
+ private final String callLinkAuthKey;
private final KeyValueStore store;
public static GroupsV2AuthorizationSignalStoreCache createAciCache(@NonNull KeyValueStore store) {
@@ -38,21 +44,30 @@ public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Auth
}
private GroupsV2AuthorizationSignalStoreCache(@NonNull KeyValueStore store, @NonNull String key) {
- this.store = store;
- this.key = key;
+ this.store = store;
+ this.key = key;
+ this.callLinkAuthKey = CALL_LINK_AUTH_PREFIX + key;
}
@Override
public void clear() {
store.beginWrite()
.remove(key)
+ .remove(callLinkAuthKey)
.commit();
Log.i(TAG, "Cleared local response cache");
}
@Override
- public @NonNull Map read() {
+ public @NonNull GroupsV2Api.CredentialResponseMaps read() {
+ Map credentials = read(key, AuthCredentialWithPniResponse::new);
+ Map callLinkCredentials = read(callLinkAuthKey, CallLinkAuthCredentialResponse::new);
+
+ return new GroupsV2Api.CredentialResponseMaps(credentials, callLinkCredentials);
+ }
+
+ public @NonNull Map read(@NonNull String key, @NonNull CredentialConstructor factory) {
byte[] credentialBlob = store.getBlob(key, null);
if (credentialBlob == null) {
@@ -61,11 +76,11 @@ public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Auth
}
try {
- TemporalAuthCredentialResponses temporalCredentials = TemporalAuthCredentialResponses.parseFrom(credentialBlob);
- HashMap result = new HashMap<>(temporalCredentials.getCredentialResponseCount());
+ TemporalAuthCredentialResponses temporalCredentials = TemporalAuthCredentialResponses.parseFrom(credentialBlob);
+ HashMap result = new HashMap<>(temporalCredentials.getCredentialResponseCount());
for (TemporalAuthCredentialResponse credential : temporalCredentials.getCredentialResponseList()) {
- result.put(credential.getDate(), new AuthCredentialWithPniResponse(credential.getAuthCredentialResponse().toByteArray()));
+ result.put(credential.getDate(), factory.apply(credential.getAuthCredentialResponse().toByteArray()));
}
Log.i(TAG, String.format(Locale.US, "Loaded %d credentials from local storage", result.size()));
@@ -77,10 +92,15 @@ public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Auth
}
@Override
- public void write(@NonNull Map values) {
+ public void write(@NonNull GroupsV2Api.CredentialResponseMaps values) {
+ write(key, values.getAuthCredentialWithPniResponseHashMap());
+ write(callLinkAuthKey, values.getCallLinkAuthCredentialResponseHashMap());
+ }
+
+ private void write(@NonNull String key, @NonNull Map values) {
TemporalAuthCredentialResponses.Builder builder = TemporalAuthCredentialResponses.newBuilder();
- for (Map.Entry entry : values.entrySet()) {
+ for (Map.Entry entry : values.entrySet()) {
builder.addCredentialResponse(TemporalAuthCredentialResponse.newBuilder()
.setDate(entry.getKey())
.setAuthCredentialResponse(ByteString.copyFrom(entry.getValue().serialize())));
@@ -92,4 +112,8 @@ public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Auth
Log.i(TAG, String.format(Locale.US, "Written %d credentials to local storage", values.size()));
}
+
+ private interface CredentialConstructor {
+ T apply(byte[] bytes) throws InvalidInputException;
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
index 2e221190a9..20d36ebfab 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
@@ -121,6 +121,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.WebRtcData;
+import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId;
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage;
import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage;
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
@@ -133,8 +134,8 @@ import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.LinkUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
-import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
+import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -1336,8 +1337,27 @@ public class MessageContentProcessor {
return;
}
- GroupId groupId = GroupId.push(callEvent.getConversationId().toByteArray());
- RecipientId recipientId = Recipient.externalGroupExact(groupId).getId();
+ RecipientId recipientId;
+ switch (type) {
+ case AD_HOC_CALL:
+ CallLinkRoomId callLinkRoomId = CallLinkRoomId.fromBytes(callEvent.getConversationId().toByteArray());
+
+ recipientId = SignalDatabase.recipients().getByCallLinkRoomId(callLinkRoomId).orElse(null);
+ break;
+ case GROUP_CALL:
+ GroupId groupId = GroupId.push(callEvent.getConversationId().toByteArray());
+
+ recipientId = Recipient.externalGroupExact(groupId).getId();
+ break;
+ default:
+ warn(envelopeTimestamp, "Group/Ad-hoc call event has a bad type " + type + ". Ignoring.");
+ return;
+ }
+
+ if (recipientId == null) {
+ Log.w(TAG, "Could not find a matching group or ad-hoc call. Dropping sync event.");
+ return;
+ }
CallTable.Call call = SignalDatabase.calls().getCallById(callId, recipientId);
if (call != null) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt
index ba177b62d6..0a32c97d42 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt
@@ -6,12 +6,17 @@ import com.mobilecoin.lib.exceptions.SerializationException
import org.signal.core.util.Hex
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.util.Pair
+import org.signal.ringrtc.CallException
+import org.signal.ringrtc.CallLinkRootKey
+import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.contactshare.Contact
+import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.crypto.SecurityEvent
+import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.GroupTable
@@ -45,6 +50,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackSyncJob
import org.thoughtcrime.securesms.jobs.PushProcessEarlyMessagesJob
+import org.thoughtcrime.securesms.jobs.RefreshCallLinkDetailsJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -79,6 +85,9 @@ import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil
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
+import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
@@ -101,6 +110,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelo
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.StoryMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.Blocked
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLinkUpdate
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.Configuration
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.FetchLatest
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.MessageRequestResponse
@@ -110,6 +120,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMe
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.StickerPackOperation
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.ViewOnceOpen
import java.io.IOException
+import java.time.Instant
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
@@ -142,6 +153,7 @@ object SyncMessageProcessor {
syncMessage.hasKeys() && syncMessage.keys.hasStorageService() -> handleSynchronizeKeys(syncMessage.keys.storageService, envelope.timestamp)
syncMessage.hasContacts() -> handleSynchronizeContacts(syncMessage.contacts, envelope.timestamp)
syncMessage.hasCallEvent() -> handleSynchronizeCallEvent(syncMessage.callEvent, envelope.timestamp)
+ syncMessage.hasCallLinkUpdate() -> handleSynchronizeCallLink(syncMessage.callLinkUpdate, envelope.timestamp)
else -> warn(envelope.timestamp, "Contains no known sync types...")
}
}
@@ -1155,6 +1167,54 @@ object SyncMessageProcessor {
}
}
+ private fun handleSynchronizeCallLink(callLinkUpdate: CallLinkUpdate, envelopeTimestamp: Long) {
+ if (!callLinkUpdate.hasRootKey()) {
+ log(envelopeTimestamp, "Synchronize call link missing root key, ignoring.")
+ return
+ }
+
+ val callLinkRootKey = try {
+ CallLinkRootKey(callLinkUpdate.rootKey.toByteArray())
+ } catch (e: CallException) {
+ log(envelopeTimestamp, "Synchronize call link has invalid root key, ignoring.")
+ return
+ }
+
+ val roomId = CallLinkRoomId.fromCallLinkRootKey(callLinkRootKey)
+ if (SignalDatabase.callLinks.callLinkExists(roomId)) {
+ log(envelopeTimestamp, "Synchronize call link for a link we already know about. Updating credentials.")
+ SignalDatabase.callLinks.updateCallLinkCredentials(
+ roomId,
+ CallLinkCredentials(
+ callLinkUpdate.rootKey.toByteArray(),
+ callLinkUpdate.adminPassKey?.toByteArray()
+ )
+ )
+
+ return
+ }
+
+ SignalDatabase.callLinks.insertCallLink(
+ CallLinkTable.CallLink(
+ recipientId = RecipientId.UNKNOWN,
+ roomId = roomId,
+ credentials = CallLinkCredentials(
+ linkKeyBytes = callLinkRootKey.keyBytes,
+ adminPassBytes = callLinkUpdate.adminPassKey?.toByteArray()
+ ),
+ state = SignalCallLinkState(
+ name = "",
+ restrictions = CallLinkState.Restrictions.UNKNOWN,
+ revoked = false,
+ expiration = Instant.MIN
+ ),
+ avatarColor = AvatarColor.random()
+ )
+ )
+
+ ApplicationDependencies.getJobManager().add(RefreshCallLinkDetailsJob(callLinkUpdate))
+ }
+
private fun handleSynchronizeOneToOneCallEvent(callEvent: SyncMessage.CallEvent, envelopeTimestamp: Long) {
val callId: Long = callEvent.id
val timestamp: Long = callEvent.timestamp
@@ -1207,10 +1267,28 @@ object SyncMessageProcessor {
return
}
- val groupId: GroupId = GroupId.push(callEvent.conversationId.toByteArray())
- val recipientId = Recipient.externalGroupExact(groupId).id
+ val recipient: Recipient? = when (type) {
+ CallTable.Type.AD_HOC_CALL -> {
+ val callLinkRoomId = CallLinkRoomId.fromBytes(callEvent.conversationId.toByteArray())
+ val callLink = SignalDatabase.callLinks.getOrCreateCallLinkByRoomId(callLinkRoomId)
+ Recipient.resolved(callLink.recipientId)
+ }
+ CallTable.Type.GROUP_CALL -> {
+ val groupId: GroupId = GroupId.push(callEvent.conversationId.toByteArray())
+ Recipient.externalGroupExact(groupId)
+ }
+ else -> {
+ warn(envelopeTimestamp, "Unexpected type $type. Ignoring.")
+ null
+ }
+ }
- val call = SignalDatabase.calls.getCallById(callId, recipientId)
+ if (recipient == null) {
+ warn(envelopeTimestamp, "Could not process conversation id.")
+ return
+ }
+
+ val call = SignalDatabase.calls.getCallById(callId, recipient.id)
if (call != null) {
if (call.type !== type) {
@@ -1221,7 +1299,7 @@ object SyncMessageProcessor {
CallTable.Event.DELETE -> SignalDatabase.calls.deleteGroupCall(call)
CallTable.Event.ACCEPTED -> {
if (call.timestamp < callEvent.timestamp) {
- SignalDatabase.calls.setTimestamp(call.callId, recipientId, callEvent.timestamp)
+ SignalDatabase.calls.setTimestamp(call.callId, recipient.id, callEvent.timestamp)
}
if (callEvent.direction == SyncMessage.CallEvent.Direction.INCOMING) {
SignalDatabase.calls.acceptIncomingGroupCall(call)
@@ -1234,8 +1312,8 @@ object SyncMessageProcessor {
}
} else {
when (event) {
- CallTable.Event.DELETE -> SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(callEvent.id, recipientId, direction, timestamp)
- CallTable.Event.ACCEPTED -> SignalDatabase.calls.insertAcceptedGroupCall(callEvent.id, recipientId, direction, timestamp)
+ CallTable.Event.DELETE -> SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(callEvent.id, recipient.id, direction, timestamp)
+ CallTable.Event.ACCEPTED -> SignalDatabase.calls.insertAcceptedGroupCall(callEvent.id, recipient.id, direction, timestamp)
CallTable.Event.NOT_ACCEPTED -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId())
else -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId())
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.kt b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.kt
index c8fbb4504a..8ed5a1b217 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.kt
@@ -150,6 +150,12 @@ open class SignalServiceNetworkAccess(context: Context) {
throw AssertionError(e)
}
+ private val genericServerPublicParams: ByteArray = try {
+ Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
+ } catch (e: IOException) {
+ throw AssertionError(e)
+ }
+
private val baseGHostConfigs: List = listOf(
HostConfig("https://www.google.com", G_HOST, GMAIL_CONNECTION_SPEC),
HostConfig("https://android.clients.google.com", G_HOST, PLAY_CONNECTION_SPEC),
@@ -173,7 +179,8 @@ open class SignalServiceNetworkAccess(context: Context) {
networkInterceptors = interceptors,
dns = Optional.of(DNS),
signalProxy = Optional.empty(),
- zkGroupServerPublicParams = zkGroupServerPublicParams
+ zkGroupServerPublicParams = zkGroupServerPublicParams,
+ genericServerPublicParams = genericServerPublicParams
)
private val censorshipConfiguration: Map = mapOf(
@@ -224,7 +231,8 @@ open class SignalServiceNetworkAccess(context: Context) {
networkInterceptors = interceptors,
dns = Optional.of(DNS),
signalProxy = if (SignalStore.proxy().isProxyEnabled) Optional.ofNullable(SignalStore.proxy().proxy) else Optional.empty(),
- zkGroupServerPublicParams = zkGroupServerPublicParams
+ zkGroupServerPublicParams = zkGroupServerPublicParams,
+ genericServerPublicParams = genericServerPublicParams
)
open fun getConfiguration(): SignalServiceConfiguration {
@@ -291,7 +299,8 @@ open class SignalServiceNetworkAccess(context: Context) {
networkInterceptors = interceptors,
dns = Optional.of(DNS),
signalProxy = Optional.empty(),
- zkGroupServerPublicParams = zkGroupServerPublicParams
+ zkGroupServerPublicParams = zkGroupServerPublicParams,
+ genericServerPublicParams = genericServerPublicParams
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
index d312b851f3..daec50a340 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
@@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.profiles.ProfileName;
+import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
@@ -1207,6 +1208,14 @@ public class Recipient {
return FeatureFlags.phoneNumberPrivacy() && needsPniSignature;
}
+ public boolean isCallLink() {
+ throw new UnsupportedOperationException();
+ }
+
+ public @NonNull CallLinkRoomId requireCallLinkRoomId() {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt
index cd8abe98b1..0c2ec36161 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt
@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.service.webrtc
import com.google.protobuf.ByteString
-import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.ringrtc.RemotePeer
@@ -56,11 +55,17 @@ object CallEventSyncMessageUtil {
event: CallEvent.Event
): CallEvent {
val recipient = Recipient.resolved(recipientId)
- val isGroupCall = recipient.isGroup
- val conversationId: ByteString = if (isGroupCall) {
- recipient.requireGroupId().decodedId.toProtoByteString()
- } else {
- recipient.requireServiceId().toByteString()
+ val callType = when {
+ recipient.isCallLink -> CallEvent.Type.AD_HOC_CALL
+ recipient.isGroup -> CallEvent.Type.GROUP_CALL
+ isVideoCall -> CallEvent.Type.VIDEO_CALL
+ else -> CallEvent.Type.AUDIO_CALL
+ }
+
+ val conversationId: ByteString = when {
+ recipient.isCallLink -> recipient.requireCallLinkRoomId().encodeForProto()
+ recipient.isGroup -> ByteString.copyFrom(recipient.requireGroupId().decodedId)
+ else -> recipient.requireServiceId().toByteString()
}
return CallEvent
@@ -68,13 +73,7 @@ object CallEventSyncMessageUtil {
.setConversationId(conversationId)
.setId(callId)
.setTimestamp(timestamp)
- .setType(
- when {
- isGroupCall -> CallEvent.Type.GROUP_CALL
- isVideoCall -> CallEvent.Type.VIDEO_CALL
- else -> CallEvent.Type.AUDIO_CALL
- }
- )
+ .setType(callType)
.setDirection(if (isOutgoing) CallEvent.Direction.OUTGOING else CallEvent.Direction.INCOMING)
.setEvent(event)
.build()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java
index 083b408aa4..71b09f78b2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java
@@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.ringrtc.CameraEventListener;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
+import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -81,6 +82,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -992,6 +994,10 @@ private void processStateless(@NonNull Function1 = ApplicationDependencies.getCallLinksService().getCreateCallLinkAuthCredential(request)
+ if (serviceResponse.result.isAbsent()) {
+ throw IOException("Failed to create credential response", serviceResponse.applicationError.or(serviceResponse.executionError).get())
+ }
+
+ Log.d(TAG, "Requesting call link credential.")
+
+ val createCallLinkCredential: CreateCallLinkCredential = requestContext.receiveResponse(
+ serviceResponse.result.get(),
+ userUuid,
+ genericServerPublicParams
+ )
+
+ Log.d(TAG, "Requesting and returning call link presentation.")
+
+ return createCallLinkCredential.present(
+ roomId,
+ userUuid,
+ genericServerPublicParams,
+ CallLinkSecretParams.deriveFromRootKey(linkRootKey)
+ )
+ }
+
+ private fun requestCallLinkAuthCredentialPresentation(
+ linkRootKey: ByteArray
+ ): CallLinkAuthCredentialPresentation {
+ return ApplicationDependencies.getGroupsV2Authorization().getCallLinkAuthorizationForToday(
+ genericServerPublicParams,
+ CallLinkSecretParams.deriveFromRootKey(linkRootKey)
+ )
+ }
+
+ fun createCallLink(
+ callLinkCredentials: CallLinkCredentials
+ ): Single {
+ return Single.create { emitter ->
+ Log.d(TAG, "Generating keys.")
+
+ val rootKey = CallLinkRootKey(callLinkCredentials.linkKeyBytes)
+ val adminPassKey: ByteArray = requireNotNull(callLinkCredentials.adminPassBytes)
+ val roomId: ByteArray = rootKey.deriveRoomId()
+
+ Log.d(TAG, "Generating credential.")
+ val credentialPresentation = try {
+ requestCreateCallLinkCredentailPresentation(
+ rootKey.keyBytes,
+ roomId
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create call link credential.", e)
+ emitter.onError(e)
+ return@create
+ }
+
+ Log.d(TAG, "Creating call link.")
+
+ val publicParams = CallLinkSecretParams.deriveFromRootKey(rootKey.keyBytes).publicParams
+
+ // Credential
+ callManager.createCallLink(
+ SignalStore.internalValues().groupCallingServer(),
+ credentialPresentation.serialize(),
+ rootKey,
+ adminPassKey,
+ publicParams.serialize()
+ ) { result ->
+ if (result.isSuccess) {
+ Log.d(TAG, "Successfully created call link.")
+ emitter.onSuccess(
+ CreateCallLinkResult.Success(
+ credentials = CallLinkCredentials(rootKey.keyBytes, adminPassKey),
+ state = result.value!!.toAppState()
+ )
+ )
+ } else {
+ Log.w(TAG, "Failed to create call link with failure status ${result.status}")
+ emitter.onSuccess(CreateCallLinkResult.Failure(result.status))
+ }
+ }
+ }
+ }
+
+ fun readCallLink(
+ credentials: CallLinkCredentials
+ ): Single {
+ return Single.create { emitter ->
+
+ callManager.readCallLink(
+ SignalStore.internalValues().groupCallingServer(),
+ requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes).serialize(),
+ CallLinkRootKey(credentials.linkKeyBytes)
+ ) {
+ if (it.isSuccess) {
+ emitter.onSuccess(ReadCallLinkResult.Success(it.value!!.toAppState()))
+ } else {
+ Log.w(TAG, "Failed to read call link with failure status ${it.status}")
+ emitter.onSuccess(ReadCallLinkResult.Failure(it.status))
+ }
+ }
+ }
+ }
+
+ fun updateCallLinkName(
+ credentials: CallLinkCredentials,
+ name: String
+ ): Single {
+ if (credentials.adminPassBytes == null) {
+ return Single.just(UpdateCallLinkResult.NotAuthorized)
+ }
+
+ return Single.create { emitter ->
+ val credentialPresentation = requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes)
+
+ callManager.updateCallLinkName(
+ SignalStore.internalValues().groupCallingServer(),
+ credentialPresentation.serialize(),
+ CallLinkRootKey(credentials.linkKeyBytes),
+ credentials.adminPassBytes,
+ name
+ ) { result ->
+ if (result.isSuccess) {
+ emitter.onSuccess(UpdateCallLinkResult.Success(result.value!!.toAppState()))
+ } else {
+ emitter.onSuccess(UpdateCallLinkResult.Failure(result.status))
+ }
+ }
+ }
+ }
+
+ fun updateCallLinkRestrictions(
+ credentials: CallLinkCredentials,
+ restrictions: Restrictions
+ ): Single {
+ if (credentials.adminPassBytes == null) {
+ return Single.just(UpdateCallLinkResult.NotAuthorized)
+ }
+
+ return Single.create { emitter ->
+ val credentialPresentation = requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes)
+
+ callManager.updateCallLinkRestrictions(
+ SignalStore.internalValues().groupCallingServer(),
+ credentialPresentation.serialize(),
+ CallLinkRootKey(credentials.linkKeyBytes),
+ credentials.adminPassBytes,
+ restrictions
+ ) { result ->
+ if (result.isSuccess) {
+ emitter.onSuccess(UpdateCallLinkResult.Success(result.value!!.toAppState()))
+ } else {
+ emitter.onSuccess(UpdateCallLinkResult.Failure(result.status))
+ }
+ }
+ }
+ }
+
+ fun updateCallLinkRevoked(
+ credentials: CallLinkCredentials,
+ revoked: Boolean
+ ): Single {
+ if (credentials.adminPassBytes == null) {
+ return Single.just(UpdateCallLinkResult.NotAuthorized)
+ }
+
+ return Single.create { emitter ->
+ val credentialPresentation = requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes)
+
+ callManager.updateCallLinkRevoked(
+ SignalStore.internalValues().groupCallingServer(),
+ credentialPresentation.serialize(),
+ CallLinkRootKey(credentials.linkKeyBytes),
+ credentials.adminPassBytes,
+ revoked
+ ) { result ->
+ if (result.isSuccess) {
+ emitter.onSuccess(UpdateCallLinkResult.Success(result.value!!.toAppState()))
+ } else {
+ emitter.onSuccess(UpdateCallLinkResult.Failure(result.status))
+ }
+ }
+ }
+ }
+
+ companion object {
+
+ private val TAG = Log.tag(SignalCallLinkManager::class.java)
+
+ private fun CallLinkState.toAppState(): SignalCallLinkState {
+ return SignalCallLinkState(
+ name = name,
+ expiration = expiration,
+ restrictions = restrictions,
+ revoked = hasBeenRevoked()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkState.kt
new file mode 100644
index 0000000000..82d625dcff
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkState.kt
@@ -0,0 +1,19 @@
+/**
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.service.webrtc.links
+
+import org.signal.ringrtc.CallLinkState.Restrictions
+import java.time.Instant
+
+/**
+ * Adapter class between our app code and RingRTC CallLinkState.
+ */
+data class SignalCallLinkState(
+ val name: String = "",
+ val restrictions: Restrictions = Restrictions.UNKNOWN,
+ @get:JvmName("hasBeenRevoked") val revoked: Boolean = false,
+ val expiration: Instant = Instant.MAX
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/UpdateCallLinkResult.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/UpdateCallLinkResult.kt
new file mode 100644
index 0000000000..128a22f102
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/UpdateCallLinkResult.kt
@@ -0,0 +1,21 @@
+/**
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.service.webrtc.links
+
+/**
+ * Result type for call link updates.
+ */
+sealed interface UpdateCallLinkResult {
+ data class Success(
+ val state: SignalCallLinkState
+ ) : UpdateCallLinkResult
+
+ class Failure(
+ val status: Short
+ ) : UpdateCallLinkResult
+
+ object NotAuthorized : UpdateCallLinkResult
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java
index 761f1452ee..818a42b6f3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java
@@ -25,10 +25,15 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
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.CallLinkRootKey;
+import org.signal.ringrtc.CallLinkState;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.WebRtcCallActivity;
+import org.thoughtcrime.securesms.calls.links.CallLinks;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
+import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
+import org.thoughtcrime.securesms.database.CallLinkTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -39,11 +44,16 @@ import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.proxy.ProxyBottomSheetFragment;
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;
+import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.signalservice.api.push.ServiceId;
import java.io.IOException;
+import java.time.Instant;
import java.util.Optional;
public class CommunicationActions {
@@ -329,6 +339,39 @@ public class CommunicationActions {
}
}
+ public static void handlePotentialCallLinkUrl(@NonNull FragmentActivity activity, @NonNull String potentialUrl) {
+ CallLinkRootKey rootKey = CallLinks.parseUrl(potentialUrl);
+ if (rootKey == null) {
+ Log.w(TAG, "Failed to parse root key from call link.");
+ // TODO [alex] -- Display a dialog informing them that the URL was invalid.
+ return;
+ }
+
+ SimpleTask.run(() -> {
+ CallLinkRoomId roomId = CallLinkRoomId.fromBytes(rootKey.deriveRoomId());
+ if (!SignalDatabase.callLinks().callLinkExists(roomId)) {
+ SignalDatabase.callLinks().insertCallLink(new CallLinkTable.CallLink(
+ RecipientId.UNKNOWN,
+ roomId,
+ new CallLinkCredentials(
+ rootKey.getKeyBytes(),
+ null
+ ),
+ new SignalCallLinkState("", CallLinkState.Restrictions.UNKNOWN, false, Instant.MIN),
+ AvatarColor.random()
+ ));
+ }
+
+ return SignalDatabase.recipients().getByCallLinkRoomId(roomId).map(Recipient::resolved);
+ }, callLinkRecipient -> {
+ if (callLinkRecipient.isEmpty()) {
+ // TODO [alex] -- Display a dialog informing them some error happened.
+ } else {
+ startVideoCall(activity, callLinkRecipient.get());
+ }
+ });
+ }
+
private static void startInsecureCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient) {
try {
Intent dialIntent = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + recipient.requireSmsAddress()));
diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java
index d5c26521e5..8659dac6cd 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java
+++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java
@@ -41,6 +41,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
+import org.whispersystems.signalservice.api.services.CallLinksService;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
@@ -217,6 +218,10 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie
return null;
}
+ @NonNull @Override public CallLinksService provideCallLinksService(@NonNull SignalServiceConfiguration signalServiceConfiguration, @NonNull GroupsV2Operations groupsV2Operations) {
+ return null;
+ }
+
@Override
public @NonNull ProfileService provideProfileService(@NonNull ClientZkProfileOperations profileOperations, @NonNull SignalServiceMessageReceiver signalServiceMessageReceiver, @NonNull SignalWebSocket signalWebSocket) {
return null;
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/CredentialResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/CredentialResponse.java
index 5dda8e34dc..02d8184e9d 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/CredentialResponse.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/CredentialResponse.java
@@ -7,7 +7,14 @@ public class CredentialResponse {
@JsonProperty
private TemporalCredential[] credentials;
+ @JsonProperty
+ private TemporalCredential[] callLinkAuthCredentials;
+
public TemporalCredential[] getCredentials() {
return credentials;
}
+
+ public TemporalCredential[] getCallLinkAuthCredentials() {
+ return callLinkAuthCredentials;
+ }
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java
index a800b68d3c..c603c652ad 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java
@@ -4,12 +4,11 @@ import com.google.protobuf.ByteString;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
-import org.signal.libsignal.zkgroup.auth.AuthCredential;
import org.signal.libsignal.zkgroup.auth.AuthCredentialPresentation;
-import org.signal.libsignal.zkgroup.auth.AuthCredentialResponse;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPni;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
+import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.storageservice.protos.groups.AvatarUploadAttributes;
@@ -29,8 +28,10 @@ import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenExcept
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
public class GroupsV2Api {
@@ -46,7 +47,7 @@ public class GroupsV2Api {
/**
* Provides 7 days of credentials, which you should cache.
*/
- public HashMap getCredentials(long todaySeconds)
+ public CredentialResponseMaps getCredentials(long todaySeconds)
throws IOException
{
return parseCredentialResponse(socket.retrieveGroupsV2Credentials(todaySeconds));
@@ -174,10 +175,11 @@ public class GroupsV2Api {
return socket.getGroupExternalCredential(authorization);
}
- private static HashMap parseCredentialResponse(CredentialResponse credentialResponse)
+ private static CredentialResponseMaps parseCredentialResponse(CredentialResponse credentialResponse)
throws IOException
{
- HashMap credentials = new HashMap<>();
+ HashMap credentials = new HashMap<>();
+ HashMap callLinkCredentials = new HashMap<>();
for (TemporalCredential credential : credentialResponse.getCredentials()) {
AuthCredentialWithPniResponse authCredentialWithPniResponse;
@@ -190,6 +192,44 @@ public class GroupsV2Api {
credentials.put(credential.getRedemptionTime(), authCredentialWithPniResponse);
}
- return credentials;
+ for (TemporalCredential credential : credentialResponse.getCallLinkAuthCredentials()) {
+ CallLinkAuthCredentialResponse callLinkAuthCredentialResponse;
+ try {
+ callLinkAuthCredentialResponse = new CallLinkAuthCredentialResponse(credential.getCredential());
+ } catch (InvalidInputException e) {
+ throw new IOException(e);
+ }
+
+ callLinkCredentials.put(credential.getRedemptionTime(), callLinkAuthCredentialResponse);
+ }
+
+ return new CredentialResponseMaps(credentials, callLinkCredentials);
+ }
+
+ public static class CredentialResponseMaps {
+ private final Map authCredentialWithPniResponseHashMap;
+ private final Map callLinkAuthCredentialResponseHashMap;
+
+ public CredentialResponseMaps(Map authCredentialWithPniResponseHashMap,
+ Map callLinkAuthCredentialResponseHashMap)
+ {
+ this.authCredentialWithPniResponseHashMap = authCredentialWithPniResponseHashMap;
+ this.callLinkAuthCredentialResponseHashMap = callLinkAuthCredentialResponseHashMap;
+ }
+
+ public Map getAuthCredentialWithPniResponseHashMap() {
+ return authCredentialWithPniResponseHashMap;
+ }
+
+ public Map getCallLinkAuthCredentialResponseHashMap() {
+ return callLinkAuthCredentialResponseHashMap;
+ }
+
+ public CredentialResponseMaps createUnmodifiableCopy() {
+ return new CredentialResponseMaps(
+ Map.copyOf(authCredentialWithPniResponseHashMap),
+ Map.copyOf(callLinkAuthCredentialResponseHashMap)
+ );
+ }
}
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java
index 51b8ef766b..0b530423f1 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java
@@ -8,7 +8,9 @@ package org.whispersystems.signalservice.api.messages.multidevice;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent;
+import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLinkUpdate;
import java.util.LinkedList;
import java.util.List;
@@ -34,6 +36,7 @@ public class SignalServiceSyncMessage {
private final Optional outgoingPaymentMessage;
private final Optional> views;
private final Optional callEvent;
+ private final Optional callLinkUpdate;
private SignalServiceSyncMessage(Optional sent,
Optional contacts,
@@ -50,7 +53,8 @@ public class SignalServiceSyncMessage {
Optional messageRequestResponse,
Optional outgoingPaymentMessage,
Optional> views,
- Optional callEvent)
+ Optional callEvent,
+ Optional callLinkUpdate)
{
this.sent = sent;
this.contacts = contacts;
@@ -68,6 +72,7 @@ public class SignalServiceSyncMessage {
this.outgoingPaymentMessage = outgoingPaymentMessage;
this.views = views;
this.callEvent = callEvent;
+ this.callLinkUpdate = callLinkUpdate;
}
public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) {
@@ -86,6 +91,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -105,6 +111,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -124,6 +131,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -143,6 +151,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -162,6 +171,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -181,6 +191,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.of(views),
+ Optional.empty(),
Optional.empty());
}
@@ -200,6 +211,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -222,6 +234,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -241,6 +254,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -260,6 +274,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -279,6 +294,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -298,6 +314,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -317,6 +334,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -336,6 +354,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -355,6 +374,7 @@ public class SignalServiceSyncMessage {
Optional.of(messageRequestResponse),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -374,6 +394,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.of(outgoingPaymentMessage),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
@@ -393,7 +414,28 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
- Optional.of(callEvent));
+ Optional.of(callEvent),
+ Optional.empty());
+ }
+
+ public static SignalServiceSyncMessage forCallLinkUpdate(@Nonnull CallLinkUpdate callLinkUpdate) {
+ return new SignalServiceSyncMessage(Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of(callLinkUpdate));
}
public static SignalServiceSyncMessage empty() {
@@ -412,6 +454,7 @@ public class SignalServiceSyncMessage {
Optional.empty(),
Optional.empty(),
Optional.empty(),
+ Optional.empty(),
Optional.empty());
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CallLinksService.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CallLinksService.kt
new file mode 100644
index 0000000000..18e74e8fd4
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CallLinksService.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.signalservice.api.services
+
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
+import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
+import org.whispersystems.signalservice.api.util.CredentialsProvider
+import org.whispersystems.signalservice.internal.ServiceResponse
+import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
+import org.whispersystems.signalservice.internal.push.PushServiceSocket
+import java.io.IOException
+
+class CallLinksService(
+ configuration: SignalServiceConfiguration,
+ credentialsProvider: CredentialsProvider,
+ signalAgent: String,
+ groupsV2Operations: GroupsV2Operations,
+ automaticNetworkRetry: Boolean
+) {
+
+ private val pushServiceSocket = PushServiceSocket(
+ configuration,
+ credentialsProvider,
+ signalAgent,
+ groupsV2Operations.profileOperations,
+ automaticNetworkRetry
+ )
+
+ fun getCreateCallLinkAuthCredential(request: CreateCallLinkCredentialRequest): ServiceResponse {
+ return try {
+ ServiceResponse.forResult(pushServiceSocket.getCallLinkAuthResponse(request), 200, "")
+ } catch (e: IOException) {
+ ServiceResponse.forUnknownError(e)
+ }
+ }
+}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.kt
index 2349b99eda..5671e22f04 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.kt
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/configuration/SignalServiceConfiguration.kt
@@ -17,5 +17,6 @@ class SignalServiceConfiguration(
val networkInterceptors: List,
val dns: Optional,
val signalProxy: Optional,
- val zkGroupServerPublicParams: ByteArray
+ val zkGroupServerPublicParams: ByteArray,
+ val genericServerPublicParams: ByteArray
)
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthRequest.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthRequest.kt
new file mode 100644
index 0000000000..a2f696d509
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthRequest.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.internal.push
+
+import com.fasterxml.jackson.annotation.JsonCreator
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest
+import org.whispersystems.util.Base64
+
+/**
+ * Request body to create a call link credential response.
+ */
+data class CreateCallLinkAuthRequest @JsonCreator constructor(
+ val createCallLinkCredentialRequest: String
+) {
+ companion object {
+ @JvmStatic
+ fun create(createCallLinkCredentialRequest: CreateCallLinkCredentialRequest): CreateCallLinkAuthRequest {
+ return CreateCallLinkAuthRequest(
+ Base64.encodeBytes(createCallLinkCredentialRequest.serialize())
+ )
+ }
+ }
+}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthResponse.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthResponse.kt
new file mode 100644
index 0000000000..544f6d5184
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthResponse.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.internal.push
+
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonProperty
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
+import org.whispersystems.util.Base64
+
+/**
+ * Response body for CreateCallLinkAuthResponse
+ */
+data class CreateCallLinkAuthResponse @JsonCreator constructor(
+ @JsonProperty("credential") val credential: String,
+ @JsonProperty("redemptionTime") val redemptionTime: Long
+) {
+ val createCallLinkCredentialResponse: CreateCallLinkCredentialResponse
+ get() = CreateCallLinkCredentialResponse(Base64.decode(credential))
+}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
index f0327cbece..79429a1118 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
@@ -22,6 +22,8 @@ import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.libsignal.zkgroup.VerificationFailedException;
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest;
+import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
@@ -289,6 +291,7 @@ public class PushServiceSocket {
private static final String BACKUP_AUTH_CHECK = "/v1/backup/auth/check";
+ private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
private static final Map NO_HEADERS = Collections.emptyMap();
@@ -1154,6 +1157,17 @@ public class PushServiceSocket {
}
}
+ public CreateCallLinkCredentialResponse getCallLinkAuthResponse(CreateCallLinkCredentialRequest request) throws IOException {
+ String payload = JsonUtil.toJson(CreateCallLinkAuthRequest.create(request));
+ String response = makeServiceRequest(
+ CALL_LINK_CREATION_AUTH,
+ "POST",
+ payload
+ );
+
+ return JsonUtil.fromJson(response, CreateCallLinkAuthResponse.class).getCreateCallLinkCredentialResponse();
+ }
+
private AuthCredentials getAuthCredentials(String authPath) throws IOException {
String response = makeServiceRequest(authPath, "GET", null);
AuthCredentials token = JsonUtil.fromJson(response, AuthCredentials.class);
diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto
index 38da4abb65..1948f4bdcf 100644
--- a/libsignal/service/src/main/proto/SignalService.proto
+++ b/libsignal/service/src/main/proto/SignalService.proto
@@ -629,6 +629,11 @@ message SyncMessage {
optional Event event = 6;
}
+ message CallLinkUpdate {
+ optional bytes rootKey = 1;
+ optional bytes adminPassKey = 2;
+ }
+
optional Sent sent = 1;
optional Contacts contacts = 2;
optional Groups groups = 3;
@@ -648,6 +653,7 @@ message SyncMessage {
reserved /*pniIdentity*/ 17;
optional PniChangeNumber pniChangeNumber = 18;
optional CallEvent callEvent = 19;
+ optional CallLinkUpdate callLinkUpdate = 20;
}
message AttachmentPointer {