Integrate call links create/update/read apis.

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

2
.gitignore vendored
View File

@@ -28,4 +28,4 @@ jni/libspeex/.deps/
pkcs11.password
dev.keystore
maps.key
local/
local/

View File

@@ -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\""

View File

@@ -599,6 +599,14 @@
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.link" />
</intent-filter>
</activity>
<activity android:name=".conversation.v2.ConversationActivity"

View File

@@ -82,6 +82,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
handleCallLinkInIntent(getIntent());
CachedInflater.from(this).clear();
@@ -183,6 +184,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleCallLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
}
}
public void onFirstRender() {
onFirstRender = true;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
class CallLinkDetailsRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
.subscribeOn(Schedulers.io())
.subscribeBy {
when (it) {
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, it.callLinkState)
is ReadCallLinkResult.Failure -> Unit
}
}
}
}

View File

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

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLinkDetailsViewModel(
private val callLinkRoomId: CallLinkRoomId,
private val repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
val state: State<CallLinkDetailsState> = _state
val nameSnapshot: String
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
init {
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
_state.value = CallLinkDetailsState(
callLink = it
)
}
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
}
fun revoke(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CallLink, ContentValues> {
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<CallLink, Cursor> {
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) {

View File

@@ -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<CallLogRow.Call> {
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()
)

View File

@@ -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<Observer> conversationListObservers;
private final Map<Long, Set<Observer>> conversationObservers;
private final Map<Long, Set<Observer>> verboseConversationObservers;
private final Map<Long, Set<Observer>> conversationDeleteObservers;
private final Map<UUID, Set<Observer>> paymentObservers;
private final Map<Long, Set<Observer>> scheduledMessageObservers;
private final Set<Observer> allPaymentsObservers;
private final Set<Observer> chatColorsObservers;
private final Set<Observer> stickerObservers;
private final Set<Observer> stickerPackObservers;
private final Set<Observer> attachmentObservers;
private final Set<MessageObserver> messageUpdateObservers;
private final Map<Long, Set<MessageObserver>> messageInsertObservers;
private final Set<Observer> notificationProfileObservers;
private final Map<RecipientId, Set<Observer>> storyObservers;
private final Set<Observer> callUpdateObservers;
private final Set<Observer> conversationListObservers;
private final Map<Long, Set<Observer>> conversationObservers;
private final Map<Long, Set<Observer>> verboseConversationObservers;
private final Map<Long, Set<Observer>> conversationDeleteObservers;
private final Map<UUID, Set<Observer>> paymentObservers;
private final Map<Long, Set<Observer>> scheduledMessageObservers;
private final Set<Observer> allPaymentsObservers;
private final Set<Observer> chatColorsObservers;
private final Set<Observer> stickerObservers;
private final Set<Observer> stickerPackObservers;
private final Set<Observer> attachmentObservers;
private final Set<MessageObserver> messageUpdateObservers;
private final Map<Long, Set<MessageObserver>> messageInsertObservers;
private final Set<Observer> notificationProfileObservers;
private final Map<RecipientId, Set<Observer>> storyObservers;
private final Set<Observer> callUpdateObservers;
private final Map<CallLinkRoomId, Set<Observer>> 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);

View File

@@ -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<RecipientId> {
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<RecipientId> {
val recipientIds = mutableListOf<RecipientId>()
readableDatabase.query(TABLE_NAME, arrayOf(ID), "$DISTRIBUTION_LIST_ID is not NULL", null, null, null, null).use { cursor ->

View File

@@ -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
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<Long, AuthCredentialWithPniResponse> 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<Long, CallLinkAuthCredentialResponse> 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<Long, AuthCredentialWithPniResponse> credentials,
@@ -84,8 +162,8 @@ public class GroupsV2Authorization {
void clear();
@NonNull Map<Long, AuthCredentialWithPniResponse> read();
@NonNull GroupsV2Api.CredentialResponseMaps read();
void write(@NonNull Map<Long, AuthCredentialWithPniResponse> values);
void write(@NonNull GroupsV2Api.CredentialResponseMaps values);
}
}

View File

@@ -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<Long, AuthCredentialWithPniResponse> 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<Long, AuthCredentialWithPniResponse> read() {
Map<Long, AuthCredentialWithPniResponse> 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<Long, AuthCredentialWithPniResponse> values) {
public synchronized void write(@NonNull GroupsV2Api.CredentialResponseMaps values) {
inner.write(values);
this.values = Collections.unmodifiableMap(new HashMap<>(values));
this.values = values.createUnmodifiableCopy();
}
}

View File

@@ -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)

View File

@@ -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());

View File

@@ -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<MultiDeviceCallLinkSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceCallLinkSyncJob {
val data = CallLinkUpdate.parseFrom(serializedData)
return MultiDeviceCallLinkSyncJob(parameters, data)
}
}
}

View File

@@ -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<RefreshCallLinkDetailsJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): RefreshCallLinkDetailsJob {
val callLinkUpdate = CallLinkUpdate.parseFrom(serializedData)
return RefreshCallLinkDetailsJob(parameters, callLinkUpdate)
}
}
}

View File

@@ -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<Long, AuthCredentialWithPniResponse> read() {
public @NonNull GroupsV2Api.CredentialResponseMaps read() {
Map<Long, AuthCredentialWithPniResponse> credentials = read(key, AuthCredentialWithPniResponse::new);
Map<Long, CallLinkAuthCredentialResponse> callLinkCredentials = read(callLinkAuthKey, CallLinkAuthCredentialResponse::new);
return new GroupsV2Api.CredentialResponseMaps(credentials, callLinkCredentials);
}
public <T extends ByteArray> @NonNull Map<Long, T> read(@NonNull String key, @NonNull CredentialConstructor<T> 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<Long, AuthCredentialWithPniResponse> result = new HashMap<>(temporalCredentials.getCredentialResponseCount());
TemporalAuthCredentialResponses temporalCredentials = TemporalAuthCredentialResponses.parseFrom(credentialBlob);
HashMap<Long, T> 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<Long, AuthCredentialWithPniResponse> values) {
public void write(@NonNull GroupsV2Api.CredentialResponseMaps values) {
write(key, values.getAuthCredentialWithPniResponseHashMap());
write(callLinkAuthKey, values.getCallLinkAuthCredentialResponseHashMap());
}
private <T extends ByteArray> void write(@NonNull String key, @NonNull Map<Long, T> values) {
TemporalAuthCredentialResponses.Builder builder = TemporalAuthCredentialResponses.newBuilder();
for (Map.Entry<Long, AuthCredentialWithPniResponse> entry : values.entrySet()) {
for (Map.Entry<Long, T> 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 extends ByteArray> {
T apply(byte[] bytes) throws InvalidInputException;
}
}

View File

@@ -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) {

View File

@@ -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())
}

View File

@@ -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<HostConfig> = 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<Int, SignalServiceConfiguration> = 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
)
}

View File

@@ -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;

View File

@@ -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()

View File

@@ -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<WebRtcEphemeralState, WebRtcEph
}
}
public @NonNull SignalCallLinkManager getCallLinkManager() {
return new SignalCallLinkManager(Objects.requireNonNull(callManager));
}
private void processSendMessageFailureWithChangeDetection(@NonNull RemotePeer remotePeer,
@NonNull ProcessAction failureProcessAction)
{

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc.links
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.ringrtc.CallLinkRootKey
/**
* Holds onto the credentials for a given call link.
*/
@Parcelize
data class CallLinkCredentials(
val linkKeyBytes: ByteArray,
val adminPassBytes: ByteArray?
) : Parcelable {
val roomId: CallLinkRoomId by lazy {
CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(linkKeyBytes))
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CallLinkCredentials
if (!linkKeyBytes.contentEquals(other.linkKeyBytes)) return false
if (adminPassBytes != null) {
if (other.adminPassBytes == null) return false
if (!adminPassBytes.contentEquals(other.adminPassBytes)) return false
} else if (other.adminPassBytes != null) return false
return true
}
override fun hashCode(): Int {
var result = linkKeyBytes.contentHashCode()
result = 31 * result + (adminPassBytes?.contentHashCode() ?: 0)
return result
}
companion object {
/**
* Generate a new call link credential for creating a new call.
*/
fun generate(): CallLinkCredentials {
return CallLinkCredentials(
CallLinkRootKey.generate().keyBytes,
CallLinkRootKey.generateAdminPasskey()
)
}
}
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc.links
import android.os.Parcelable
import com.google.protobuf.ByteString
import kotlinx.parcelize.Parcelize
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.util.Base64
@Parcelize
class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcelable {
fun serialize(): String = Base64.encodeBytes(roomId)
fun encodeForProto(): ByteString = ByteString.copyFrom(roomId)
companion object {
@JvmStatic
fun fromBytes(byteArray: ByteArray): CallLinkRoomId {
return CallLinkRoomId(byteArray)
}
fun fromCallLinkRootKey(callLinkRootKey: CallLinkRootKey): CallLinkRoomId {
return CallLinkRoomId(callLinkRootKey.deriveRoomId())
}
}
}

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc.links
/**
* Result type for call link creation.
*/
sealed interface CreateCallLinkResult {
data class Success(
val credentials: CallLinkCredentials,
val state: SignalCallLinkState
) : CreateCallLinkResult
data class Failure(
val status: Short
) : CreateCallLinkResult
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc.links
/**
* Result type for call link reads.
*/
sealed interface ReadCallLinkResult {
data class Success(
val callLinkState: SignalCallLinkState
) : ReadCallLinkResult
data class Failure(val status: Short) : ReadCallLinkResult
}

View File

@@ -0,0 +1,250 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc.links
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log
import org.signal.core.util.or
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialPresentation
import org.signal.libsignal.zkgroup.calllinks.CallLinkSecretParams
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredential
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialPresentation
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequestContext
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
import org.signal.ringrtc.CallLinkRootKey
import org.signal.ringrtc.CallLinkState
import org.signal.ringrtc.CallLinkState.Restrictions
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
/**
* Call Link manager which encapsulates CallManager and provides a stable interface.
*
* We can remove the outer sealed class once we have the final, working builds from core.
*/
class SignalCallLinkManager(
private val callManager: CallManager
) {
private val genericServerPublicParams: GenericServerPublicParams = GenericServerPublicParams(
ApplicationDependencies.getSignalServiceNetworkAccess()
.getConfiguration()
.genericServerPublicParams
)
private fun requestCreateCallLinkCredentailPresentation(
linkRootKey: ByteArray,
roomId: ByteArray
): CreateCallLinkCredentialPresentation {
val userUuid = Recipient.self().requireServiceId().uuid()
val requestContext = CreateCallLinkCredentialRequestContext.forRoom(roomId)
val request = requestContext.request
Log.d(TAG, "Requesting call link credential response.")
val serviceResponse: ServiceResponse<CreateCallLinkCredentialResponse> = 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<CreateCallLinkResult> {
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<ReadCallLinkResult> {
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<UpdateCallLinkResult> {
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<UpdateCallLinkResult> {
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<UpdateCallLinkResult> {
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()
)
}
}
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<Long, AuthCredentialWithPniResponse> 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<Long, AuthCredentialWithPniResponse> parseCredentialResponse(CredentialResponse credentialResponse)
private static CredentialResponseMaps parseCredentialResponse(CredentialResponse credentialResponse)
throws IOException
{
HashMap<Long, AuthCredentialWithPniResponse> credentials = new HashMap<>();
HashMap<Long, AuthCredentialWithPniResponse> credentials = new HashMap<>();
HashMap<Long, CallLinkAuthCredentialResponse> 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<Long, AuthCredentialWithPniResponse> authCredentialWithPniResponseHashMap;
private final Map<Long, CallLinkAuthCredentialResponse> callLinkAuthCredentialResponseHashMap;
public CredentialResponseMaps(Map<Long, AuthCredentialWithPniResponse> authCredentialWithPniResponseHashMap,
Map<Long, CallLinkAuthCredentialResponse> callLinkAuthCredentialResponseHashMap)
{
this.authCredentialWithPniResponseHashMap = authCredentialWithPniResponseHashMap;
this.callLinkAuthCredentialResponseHashMap = callLinkAuthCredentialResponseHashMap;
}
public Map<Long, AuthCredentialWithPniResponse> getAuthCredentialWithPniResponseHashMap() {
return authCredentialWithPniResponseHashMap;
}
public Map<Long, CallLinkAuthCredentialResponse> getCallLinkAuthCredentialResponseHashMap() {
return callLinkAuthCredentialResponseHashMap;
}
public CredentialResponseMaps createUnmodifiableCopy() {
return new CredentialResponseMaps(
Map.copyOf(authCredentialWithPniResponseHashMap),
Map.copyOf(callLinkAuthCredentialResponseHashMap)
);
}
}
}

View File

@@ -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> outgoingPaymentMessage;
private final Optional<List<ViewedMessage>> views;
private final Optional<CallEvent> callEvent;
private final Optional<CallLinkUpdate> callLinkUpdate;
private SignalServiceSyncMessage(Optional<SentTranscriptMessage> sent,
Optional<ContactsMessage> contacts,
@@ -50,7 +53,8 @@ public class SignalServiceSyncMessage {
Optional<MessageRequestResponseMessage> messageRequestResponse,
Optional<OutgoingPaymentMessage> outgoingPaymentMessage,
Optional<List<ViewedMessage>> views,
Optional<CallEvent> callEvent)
Optional<CallEvent> callEvent,
Optional<CallLinkUpdate> 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());
}

View File

@@ -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<CreateCallLinkCredentialResponse> {
return try {
ServiceResponse.forResult(pushServiceSocket.getCallLinkAuthResponse(request), 200, "")
} catch (e: IOException) {
ServiceResponse.forUnknownError(e)
}
}
}

View File

@@ -17,5 +17,6 @@ class SignalServiceConfiguration(
val networkInterceptors: List<Interceptor>,
val dns: Optional<Dns>,
val signalProxy: Optional<SignalProxy>,
val zkGroupServerPublicParams: ByteArray
val zkGroupServerPublicParams: ByteArray,
val genericServerPublicParams: ByteArray
)

View File

@@ -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())
)
}
}
}

View File

@@ -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))
}

View File

@@ -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<String, String> 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);

View File

@@ -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 {