diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88bff81e9c..47712408f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -713,11 +713,10 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> - + , number: String?, chatType: Optional, callback: Consumer) { - if (recipientId.isPresent) { - launch(Recipient.resolved(recipientId.get())) - } else { - Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.") - if (SignalStore.account.isRegistered) { - Log.i(TAG, "[onContactSelected] Doing contact refresh.") - - val progress = SimpleProgressDialog.show(this) - - SimpleTask.run(lifecycle, { RecipientRepository.lookupNewE164(number!!) }, { result -> - progress.dismiss() - - when (result) { - is RecipientRepository.LookupResult.Success -> { - val resolved = Recipient.resolved(result.recipientId) - if (resolved.isRegistered && resolved.hasServiceId) { - launch(resolved) - } - } - - is RecipientRepository.LookupResult.NotFound, - is RecipientRepository.LookupResult.InvalidEntry -> { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number)) - .setPositiveButton(android.R.string.ok, null) - .show() - } - - else -> { - MaterialAlertDialogBuilder(this) - .setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } - }) - } - } - callback.accept(true) - } - - private fun launch(recipient: Recipient) { - if (recipient.isGroup) { - CommunicationActions.startVideoCall(this, recipient) { - YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content)) - } - } else { - CommunicationActions.startVoiceCall(this, recipient) { - YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content)) - } - } - } +/** + * Allows the user to start a new call by selecting a recipient. + */ +class NewCallActivity : PassphraseRequiredActivity() { companion object { - - private val TAG = Log.tag(NewCallActivity::class.java) - + @JvmStatic fun createIntent(context: Context): Intent { return Intent(context, NewCallActivity::class.java) - .putExtra( - ContactSelectionArguments.DISPLAY_MODE, - ContactSelectionDisplayMode.none() - .withPush() - .withActiveGroups() - .withGroupMembers() - .build() + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + enableEdgeToEdge() + super.onCreate(savedInstanceState, ready) + + val navigateBack = onBackPressedDispatcher::onBackPressed + + setContent { + SignalTheme { + NewCallScreen( + closeScreen = navigateBack ) - } - } - - override fun onInvite() { - startActivity(AppSettingsActivity.invite(this)) - } - - private fun handleManualRefresh() { - if (!contactsFragment.isRefreshing) { - contactsFragment.isRefreshing = true - onRefresh() - } - } - - private inner class NewCallMenuProvider : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.new_call_menu, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - android.R.id.home -> ActivityCompat.finishAfterTransition(this@NewCallActivity) - R.id.menu_refresh -> handleManualRefresh() - R.id.menu_invite -> startActivity(AppSettingsActivity.invite(this@NewCallActivity)) } - - return true } } } + +@Composable +private fun NewCallScreen( + viewModel: NewCallViewModel = viewModel { NewCallViewModel() }, + closeScreen: () -> Unit +) { + val context = LocalContext.current as FragmentActivity + + val callbacks = remember { + object : UiCallbacks { + override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query) + override fun onRecipientSelected(selection: RecipientSelection) = viewModel.startCall(selection) + override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context)) + override fun onRefresh() = viewModel.refresh() + override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage() + override fun onBackPressed() = closeScreen() + } + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.pendingCall) { + val pendingCall = uiState.pendingCall ?: return@LaunchedEffect + when (pendingCall) { + is CallType.Video -> CommunicationActions.startVideoCall(context, pendingCall.recipient, viewModel::showUserAlreadyInACall) + is CallType.Voice -> CommunicationActions.startVoiceCall(context, pendingCall.recipient, viewModel::showUserAlreadyInACall) + } + viewModel.clearPendingCall() + } + + NewCallScreenUi( + uiState = uiState, + callbacks = callbacks + ) +} + +private interface UiCallbacks : + RecipientPickerCallbacks.ListActions, + RecipientPickerCallbacks.Refresh, + RecipientPickerCallbacks.NewCall { + + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true + override fun onPendingRecipientSelectionsConsumed() = Unit + fun onUserMessageDismissed(userMessage: UserMessage) + fun onBackPressed() + + object Empty : UiCallbacks { + override fun onSearchQueryChanged(query: String) = Unit + override fun onRecipientSelected(selection: RecipientSelection) = Unit + override fun onInviteToSignal() = Unit + override fun onRefresh() = Unit + override fun onUserMessageDismissed(userMessage: UserMessage) = Unit + override fun onBackPressed() = Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun NewCallScreenUi( + uiState: NewCallUiState, + callbacks: UiCallbacks +) { + val snackbarHostState = remember { SnackbarHostState() } + + RecipientPickerScaffold( + title = stringResource(R.string.NewCallActivity__new_call), + forceSplitPane = uiState.forceSplitPane, + onNavigateUpClick = callbacks::onBackPressed, + topAppBarActions = { TopAppBarActions(callbacks) }, + snackbarHostState = snackbarHostState, + primaryContent = { + RecipientPicker( + searchQuery = uiState.searchQuery, + displayModes = setOf(RecipientPicker.DisplayMode.PUSH, RecipientPicker.DisplayMode.ACTIVE_GROUPS, RecipientPicker.DisplayMode.GROUP_MEMBERS), + isRefreshing = uiState.isRefreshingContacts, + callbacks = remember(callbacks) { + RecipientPickerCallbacks( + listActions = callbacks, + refresh = callbacks, + newCall = callbacks + ) + }, + modifier = Modifier.fillMaxSize() + ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onDismiss = callbacks::onUserMessageDismissed, + snackbarHostState = snackbarHostState + ) + + if (uiState.isLookingUpRecipient) { + Dialogs.IndeterminateProgressDialog() + } + } + ) +} + +@Composable +private fun TopAppBarActions(callbacks: UiCallbacks) { + val menuController = remember { DropdownMenus.MenuController() } + IconButton( + onClick = { menuController.show() }, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical), + contentDescription = stringResource(R.string.NewConversationActivity__accessibility_open_top_bar_menu) + ) + } + + DropdownMenus.Menu( + controller = menuController, + offsetX = 24.dp, + modifier = Modifier + ) { + DropdownMenus.Item( + text = { Text(text = stringResource(R.string.new_conversation_activity__refresh)) }, + onClick = { + callbacks.onRefresh() + menuController.hide() + } + ) + + DropdownMenus.Item( + text = { Text(text = stringResource(R.string.text_secure_normal__invite_friends)) }, + onClick = { + callbacks.onInviteToSignal() + menuController.hide() + } + ) + } +} + +@Composable +private fun UserMessagesHost( + userMessage: UserMessage?, + onDismiss: (UserMessage) -> Unit, + snackbarHostState: SnackbarHostState +) { + val context = LocalContext.current + + when (userMessage) { + null -> {} + + is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog( + message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismiss(userMessage) } + ) + + is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( + message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismiss(userMessage) } + ) + + is UserMessage.Info.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) { + snackbarHostState.showSnackbar( + message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call) + ) + onDismiss(userMessage) + } + } +} + +@AllDevicePreviews +@Composable +private fun NewCallScreenPreview() { + Previews.Preview { + NewCallScreenUi( + uiState = NewCallUiState( + forceSplitPane = false + ), + callbacks = UiCallbacks.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt new file mode 100644 index 0000000000..cb940973cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.calls.new + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType +import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage.Info +import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection + +class NewCallViewModel : ViewModel() { + companion object { + private val TAG = Log.tag(NewCallViewModel::class) + } + + private val internalUiState = MutableStateFlow(NewCallUiState()) + val uiState: StateFlow = internalUiState.asStateFlow() + + fun onSearchQueryChanged(query: String) { + internalUiState.update { it.copy(searchQuery = query) } + } + + fun startCall(selection: RecipientSelection) { + viewModelScope.launch { + when (selection) { + is RecipientSelection.WithId -> resolveAndStartCall(selection.id) + is RecipientSelection.WithIdAndPhone -> resolveAndStartCall(selection.id) + is RecipientSelection.WithPhone -> { + Log.d(TAG, "[startCall] Missing recipientId: attempting to look up.") + resolveAndStartCall(selection.phone) + } + } + } + } + + private suspend fun resolveAndStartCall(id: RecipientId) { + val recipient = withContext(Dispatchers.IO) { + Recipient.resolved(id) + } + openCall(recipient) + } + + private suspend fun resolveAndStartCall(phone: PhoneNumber) { + if (!SignalStore.account.isRegistered) { + Log.w(TAG, "[resolveAndStartCall] Cannot look up recipient: account not registered.") + return + } + internalUiState.update { it.copy(isLookingUpRecipient = true) } + + val lookupResult = withContext(Dispatchers.IO) { + RecipientRepository.lookupNewE164(inputE164 = phone.value) + } + + when (lookupResult) { + is RecipientRepository.LookupResult.Success -> { + val recipient = withContext(Dispatchers.IO) { + Recipient.resolved(lookupResult.recipientId) + } + internalUiState.update { it.copy(isLookingUpRecipient = false) } + openCall(recipient) + } + + is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = Info.RecipientNotSignalUser(phone) + ) + } + } + + is RecipientRepository.LookupResult.NetworkError -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = Info.NetworkError + ) + } + } + } + } + + private fun openCall(recipient: Recipient) { + if (!recipient.isRegistered && recipient.hasServiceId) { + Log.w(TAG, "[openCall] Unable to open call: recipient has a service ID but is not registered.") + return + } + + internalUiState.update { + it.copy( + pendingCall = if (recipient.isGroup) { + CallType.Video(recipient) + } else { + CallType.Voice(recipient) + } + ) + } + } + + fun clearPendingCall() { + internalUiState.update { it.copy(pendingCall = null) } + } + + fun showUserAlreadyInACall() { + internalUiState.update { it.copy(userMessage = Info.UserAlreadyInAnotherCall) } + } + + fun refresh() { + if (internalUiState.value.isRefreshingContacts) { + return + } + + viewModelScope.launch { + internalUiState.update { it.copy(isRefreshingContacts = true) } + + withContext(Dispatchers.IO) { + ContactDiscovery.refreshAll(AppDependencies.application, true) + } + + internalUiState.update { it.copy(isRefreshingContacts = false) } + } + } + + fun clearUserMessage() { + internalUiState.update { it.copy(userMessage = null) } + } +} + +data class NewCallUiState( + val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane, + val searchQuery: String = "", + val isLookingUpRecipient: Boolean = false, + val isRefreshingContacts: Boolean = false, + val pendingCall: CallType? = null, + val userMessage: UserMessage? = null +) { + sealed interface UserMessage { + sealed interface Info : UserMessage { + data class RecipientNotSignalUser(val phone: PhoneNumber) : Info + data object UserAlreadyInAnotherCall : Info + data object NetworkError : Info + } + } + + sealed interface CallType { + data class Voice(val recipient: Recipient) : CallType + data class Video(val recipient: Recipient) : CallType + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt index 8dde67a700..0bb5ff93b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt @@ -23,6 +23,7 @@ data class ContactSelectionArguments( val currentSelection: Set = Defaults.CURRENT_SELECTION, val canSelectSelf: Boolean = Defaults.canSelectSelf(selectionLimits), val displayChips: Boolean = Defaults.DISPLAY_CHIPS, + val showCallButtons: Boolean = Defaults.SHOW_CALL_BUTTONS, val recyclerPadBottom: Int = Defaults.RECYCLER_PADDING_BOTTOM, val recyclerChildClipping: Boolean = Defaults.RECYCLER_CHILD_CLIPPING ) { @@ -40,6 +41,7 @@ data class ContactSelectionArguments( putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection)) putBoolean(CAN_SELECT_SELF, canSelectSelf) putBoolean(DISPLAY_CHIPS, displayChips) + putBoolean(SHOW_CALL_BUTTONS, showCallButtons) putInt(RV_PADDING_BOTTOM, recyclerPadBottom) putBoolean(RV_CLIP, recyclerChildClipping) } @@ -57,6 +59,7 @@ data class ContactSelectionArguments( const val CURRENT_SELECTION = "current_selection" const val CAN_SELECT_SELF = "can_select_self" const val DISPLAY_CHIPS = "display_chips" + const val SHOW_CALL_BUTTONS = "show_call_buttons" const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom" const val RV_CLIP = "recycler_view_clipping" @@ -81,6 +84,7 @@ data class ContactSelectionArguments( currentSelection = currentSelection.toSet(), canSelectSelf = bundle.getBoolean(CAN_SELECT_SELF, intent.getBooleanExtra(CAN_SELECT_SELF, Defaults.canSelectSelf(selectionLimits))), displayChips = bundle.getBoolean(DISPLAY_CHIPS, intent.getBooleanExtra(DISPLAY_CHIPS, Defaults.DISPLAY_CHIPS)), + showCallButtons = bundle.getBoolean(SHOW_CALL_BUTTONS, intent.getBooleanExtra(SHOW_CALL_BUTTONS, Defaults.SHOW_CALL_BUTTONS)), recyclerPadBottom = bundle.getInt(RV_PADDING_BOTTOM, intent.getIntExtra(RV_PADDING_BOTTOM, Defaults.RECYCLER_PADDING_BOTTOM)), recyclerChildClipping = bundle.getBoolean(RV_CLIP, intent.getBooleanExtra(RV_CLIP, Defaults.RECYCLER_CHILD_CLIPPING)) ) @@ -98,6 +102,7 @@ data class ContactSelectionArguments( val SELECTION_LIMITS: SelectionLimits? = null val CURRENT_SELECTION: Set = emptySet() const val DISPLAY_CHIPS = true + const val SHOW_CALL_BUTTONS = false const val RECYCLER_PADDING_BOTTOM = -1 const val RECYCLER_CHILD_CLIPPING = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index c49a1ec870..5c0e09d638 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -308,14 +308,16 @@ private fun NewConversationRecipientPicker( searchQuery = uiState.searchQuery, isRefreshing = uiState.isRefreshingContacts, shouldResetContactsList = uiState.shouldResetContactsList, - callbacks = RecipientPickerCallbacks( - listActions = callbacks, - refresh = callbacks, - contextMenu = callbacks, - newConversation = callbacks, - findByUsername = callbacks, - findByPhoneNumber = callbacks - ), + callbacks = remember(callbacks) { + RecipientPickerCallbacks( + listActions = callbacks, + refresh = callbacks, + contextMenu = callbacks, + newConversation = callbacks, + findByUsername = callbacks, + findByPhoneNumber = callbacks + ) + }, modifier = modifier.fillMaxSize() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index f5ed718600..9acfbc9370 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -147,6 +147,10 @@ class NewConversationViewModel : ViewModel() { } fun refresh() { + if (internalUiState.value.isRefreshingContacts) { + return + } + viewModelScope.launch { internalUiState.update { it.copy(isRefreshingContacts = true) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt index 707f4ad413..da9c33970c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -142,6 +142,7 @@ private fun RecipientSearchResultsList( enableCreateNewGroup = callbacks.newConversation != null, enableFindByUsername = callbacks.findByUsername != null, enableFindByPhoneNumber = callbacks.findByPhoneNumber != null, + showCallButtons = callbacks.newCall != null, selectionLimits = selectionLimits, recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM }, recyclerChildClipping = clipListToPadding @@ -228,6 +229,12 @@ private fun ContactSelectionListFragment.setUpCallbacks( fragment.setNewConversationCallback(null) } + if (callbacks.newCall != null) { + fragment.setNewCallCallback { callbacks.newCall.onInviteToSignal() } + } else { + fragment.setNewCallCallback(null) + } + if (callbacks.findByUsername != null || callbacks.findByPhoneNumber != null) { fragment.setFindByCallback(object : ContactSelectionListFragment.FindByCallback { override fun onFindByUsername() = callbacks.findByUsername?.onFindByUsername() ?: Unit @@ -371,11 +378,12 @@ private fun RecipientPickerPreview() { ) } -data class RecipientPickerCallbacks( +class RecipientPickerCallbacks( val listActions: ListActions, val refresh: Refresh? = null, val contextMenu: ContextMenu? = null, val newConversation: NewConversation? = null, + val newCall: NewCall? = null, val findByUsername: FindByUsername? = null, val findByPhoneNumber: FindByPhoneNumber? = null ) { @@ -418,6 +426,10 @@ data class RecipientPickerCallbacks( fun onInviteToSignal() } + interface NewCall { + fun onInviteToSignal() + } + interface FindByUsername { fun onFindByUsername() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt index f967002892..88aed5adb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt @@ -203,11 +203,13 @@ private fun CreateGroupRecipientPicker( isRefreshing = false, listBottomPadding = 64.dp, clipListToPadding = false, - callbacks = RecipientPickerCallbacks( - listActions = callbacks, - findByUsername = callbacks, - findByPhoneNumber = callbacks - ), + callbacks = remember(callbacks) { + RecipientPickerCallbacks( + listActions = callbacks, + findByUsername = callbacks, + findByPhoneNumber = callbacks + ) + }, modifier = modifier.fillMaxSize() ) diff --git a/app/src/main/res/menu/new_call_menu.xml b/app/src/main/res/menu/new_call_menu.xml deleted file mode 100644 index feb43d2b9f..0000000000 --- a/app/src/main/res/menu/new_call_menu.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file