From 5a38143987f2e5e38beca944a4aeea8876af003b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 19 May 2023 10:28:29 -0300 Subject: [PATCH] Integrate call links create/update/read apis. --- .gitignore | 2 +- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 8 + .../thoughtcrime/securesms/MainActivity.java | 8 + .../securesms/calls/links/CallLinks.kt | 77 +++++- .../links/EditCallLinkNameDialogFragment.kt | 5 + .../securesms/calls/links/SignalCallRow.kt | 30 ++- .../calls/links/UpdateCallLinkRepository.kt | 64 +++++ ...CreateCallLinkBottomSheetDialogFragment.kt | 104 +++++--- .../links/create/CreateCallLinkRepository.kt | 57 ++++ .../links/create/CreateCallLinkViewModel.kt | 87 +++++- .../create/EnsureCallLinkCreatedResult.kt | 13 + .../links/details/CallLinkDetailsActivity.kt | 5 + .../links/details/CallLinkDetailsFragment.kt | 82 ++++-- .../details/CallLinkDetailsRepository.kt | 33 +++ .../links/details/CallLinkDetailsState.kt | 12 + .../links/details/CallLinkDetailsViewModel.kt | 65 +++++ .../calls/links/details/CallLinkViewModel.kt | 22 -- .../securesms/calls/log/CallLogAdapter.kt | 14 +- .../securesms/calls/log/CallLogContextMenu.kt | 22 +- .../securesms/calls/log/CallLogFragment.kt | 18 +- .../securesms/calls/log/CallLogRow.kt | 5 + .../securesms/database/CallLinkTable.kt | 186 ++++++++++++- .../securesms/database/CallTable.kt | 14 +- .../securesms/database/DatabaseObserver.java | 48 ++-- .../securesms/database/RecipientTable.kt | 17 ++ .../securesms/database/SignalDatabase.kt | 6 + .../dependencies/ApplicationDependencies.java | 14 + .../ApplicationDependencyProvider.java | 10 + .../groups/GroupsV2Authorization.java | 88 +++++- ...GroupsV2AuthorizationMemoryValueCache.java | 13 +- .../securesms/jobs/CallSyncEventJob.kt | 2 +- .../securesms/jobs/JobManagerFactories.java | 2 + .../jobs/MultiDeviceCallLinkSyncJob.kt | 79 ++++++ .../jobs/RefreshCallLinkDetailsJob.kt | 70 +++++ ...GroupsV2AuthorizationSignalStoreCache.java | 44 ++- .../messages/MessageContentProcessor.java | 26 +- .../messages/SyncMessageProcessor.kt | 90 ++++++- .../push/SignalServiceNetworkAccess.kt | 15 +- .../securesms/recipients/Recipient.java | 9 + .../webrtc/CallEventSyncMessageUtil.kt | 25 +- .../service/webrtc/SignalCallManager.java | 6 + .../webrtc/links/CallLinkCredentials.kt | 57 ++++ .../service/webrtc/links/CallLinkRoomId.kt | 30 +++ .../webrtc/links/CreateCallLinkResult.kt | 20 ++ .../webrtc/links/ReadCallLinkResult.kt | 17 ++ .../webrtc/links/SignalCallLinkManager.kt | 250 ++++++++++++++++++ .../webrtc/links/SignalCallLinkState.kt | 19 ++ .../webrtc/links/UpdateCallLinkResult.kt | 21 ++ .../securesms/util/CommunicationActions.java | 43 +++ .../MockApplicationDependencyProvider.java | 5 + .../api/groupsv2/CredentialResponse.java | 7 + .../api/groupsv2/GroupsV2Api.java | 52 +++- .../multidevice/SignalServiceSyncMessage.java | 47 +++- .../api/services/CallLinksService.kt | 39 +++ .../SignalServiceConfiguration.kt | 3 +- .../push/CreateCallLinkAuthRequest.kt | 26 ++ .../push/CreateCallLinkAuthResponse.kt | 22 ++ .../internal/push/PushServiceSocket.java | 14 + .../src/main/proto/SignalService.proto | 6 + 60 files changed, 1986 insertions(+), 191 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/create/EnsureCallLinkCreatedResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceCallLinkSyncJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshCallLinkDetailsJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkCredentials.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CreateCallLinkResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/ReadCallLinkResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkManager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/UpdateCallLinkResult.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CallLinksService.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthRequest.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/CreateCallLinkAuthResponse.kt 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 {