diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt index 07de07f08a..c561f45444 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ReactionMenu.kt @@ -63,7 +63,7 @@ object ReactionMenu { } if (menuState.shouldShowResendAction()) { - add(R.drawable.symbol_refresh_24, R.string.conversation_selection__menu_resend_message, ReactionAction.RESEND) + add(CoreUiR.drawable.symbol_refresh_24, R.string.conversation_selection__menu_resend_message, ReactionAction.RESEND) } if (menuState.shouldShowSaveAttachmentAction()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkFragment.kt index d38a27c537..0783c5014d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkFragment.kt @@ -1,132 +1,38 @@ package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.configure +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkScreen +import org.signal.core.ui.compose.ComposeFragment import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil -import org.thoughtcrime.securesms.util.views.SimpleProgressDialog +import org.thoughtcrime.securesms.util.viewModel /** - * Fragment providing user options to manage group links. + * Fragment wrapping [ShareableGroupLinkScreen] to let a group's members share its link and its admins manage it. */ -class ShareableGroupLinkFragment : DSLSettingsFragment( - titleId = R.string.ShareableGroupLinkDialogFragment__group_link -) { - - private var busyDialog: SimpleProgressDialog.DismissibleDialog? = null +class ShareableGroupLinkFragment : ComposeFragment() { private val groupId: GroupId.V2 get() = ShareableGroupLinkFragmentArgs.fromBundle(requireArguments()).groupId.requireV2() - private val viewModel: ShareableGroupLinkViewModel by viewModels( - factoryProducer = { - val repository = ShareableGroupLinkRepository(requireContext(), groupId) + private val viewModel: ShareableGroupLinkViewModel by viewModel { + ShareableGroupLinkViewModel(groupId) + } - ShareableGroupLinkViewModel.Factory(groupId, repository) - } - ) + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() - override fun bindAdapter(adapter: MappingAdapter) { - LiveDataUtil.combineLatest(viewModel.groupLink, viewModel.canEdit) { groupLink, canEdit -> - Pair(groupLink, canEdit) - }.observe(viewLifecycleOwner) { (groupLink, canEdit) -> - adapter.submitList(getConfiguration(groupLink, canEdit).toMappingModelList()) - } - - viewModel.toasts.observe(viewLifecycleOwner, this::toast) - - viewModel.busy.observe( - viewLifecycleOwner, - { busy -> - if (busy) { - if (busyDialog == null) { - busyDialog = SimpleProgressDialog.showDelayed(requireContext()) - } - } else { - busyDialog?.dismiss() - busyDialog = null - } + ShareableGroupLinkScreen( + state = state, + onEvent = viewModel::onEvent, + onShareClick = { + GroupLinkBottomSheetDialogFragment.show(childFragmentManager, groupId) + }, + onNavigationClick = { + requireActivity().onBackPressedDispatcher.onBackPressed() } ) } - - private fun toast(@StringRes message: Int) { - Toast.makeText(requireContext(), getString(message), Toast.LENGTH_SHORT).show() - } - - private fun getConfiguration(groupLink: GroupLinkUrlAndStatus, canEdit: Boolean): DSLConfiguration { - return configure { - switchPref( - title = DSLSettingsText.from(R.string.ShareableGroupLinkDialogFragment__group_link), - summary = if (groupLink.isEnabled) DSLSettingsText.from(formatForFullWidthWrapping(groupLink.url)) else null, - isChecked = groupLink.isEnabled, - isEnabled = canEdit, - onClick = { - viewModel.onToggleGroupLink() - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.ShareableGroupLinkDialogFragment__share), - icon = DSLSettingsIcon.from(R.drawable.ic_share_24_tinted), - isEnabled = groupLink.isEnabled, - onClick = { - GroupLinkBottomSheetDialogFragment.show(childFragmentManager, groupId) - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.ShareableGroupLinkDialogFragment__reset_link), - icon = DSLSettingsIcon.from(R.drawable.ic_reset_24_tinted), - isEnabled = groupLink.isEnabled && canEdit, - onClick = { - onResetGroupLink() - } - ) - - dividerPref() - - switchPref( - title = DSLSettingsText.from(R.string.ShareableGroupLinkDialogFragment__require_admin_approval), - summary = DSLSettingsText.from(R.string.ShareableGroupLinkDialogFragment__require_an_admin_to_approve_new_members_joining_via_the_group_link), - isEnabled = groupLink.isEnabled && canEdit, - isChecked = groupLink.isRequiresApproval, - onClick = { - viewModel.onToggleApproveMembers() - } - ) - } - } - - private fun onResetGroupLink() { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_the_group_link) - .setPositiveButton(R.string.ShareableGroupLinkDialogFragment__reset_link) { _, _ -> viewModel.onResetLink() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - /** - * Inserts zero width space characters between each character in the original ensuring it takes - * the full width of the TextView. - */ - private fun formatForFullWidthWrapping(url: String): CharSequence { - val chars = CharArray(url.length * 2) - for (i in url.indices) { - chars[i * 2] = url[i] - chars[i * 2 + 1] = '\u200B' - } - - return String(chars) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java deleted file mode 100644 index 40c841a946..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.groups.GroupChangeBusyException; -import org.signal.core.util.groups.GroupChangeFailedException; -import org.signal.core.util.groups.GroupInsufficientRightsException; -import org.signal.core.util.groups.GroupNotAMemberException; -import org.signal.core.util.logging.Log; -import org.signal.storageservice.storage.protos.groups.AccessControl; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.GroupRecord; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.GroupManager; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.util.AsynchronousCallback; - -import java.io.IOException; - -final class ShareableGroupLinkRepository { - - private static final String TAG = Log.tag(ShareableGroupLinkRepository.class); - - private final Context context; - private final GroupId.V2 groupId; - - ShareableGroupLinkRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { - this.context = context; - this.groupId = groupId; - } - - void cycleGroupLinkPassword(@NonNull AsynchronousCallback.WorkerThread callback) { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - GroupManager.cycleGroupLinkPassword(context, groupId); - callback.onComplete(null); - } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { - callback.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - void toggleGroupLinkEnabled(@NonNull AsynchronousCallback.WorkerThread callback) { - setGroupLinkEnabledState(true, false, callback); - } - - void toggleGroupLinkApprovalRequired(@NonNull AsynchronousCallback.WorkerThread callback) { - setGroupLinkEnabledState(false, true, callback); - } - - private void setGroupLinkEnabledState(boolean toggleEnabled, - boolean toggleApprovalNeeded, - @NonNull AsynchronousCallback.WorkerThread callback) - { - SignalExecutors.UNBOUNDED.execute(() -> { - GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null); - - if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { - Log.w(TAG, "Unable to find group, likely deleted."); - callback.onError(GroupChangeFailureReason.OTHER); - return; - } - - try { - GroupManager.setGroupLinkEnabledState(context, groupId, toggleGroupLinkState(groupRecord, toggleEnabled, toggleApprovalNeeded)); - callback.onComplete(null); - } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { - callback.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - @WorkerThread - private GroupManager.GroupLinkState toggleGroupLinkState(@NonNull GroupRecord groupRecord, boolean toggleEnabled, boolean toggleApprovalNeeded) { - //noinspection DataFlowIssue - AccessControl.AccessRequired currentState = groupRecord.requireV2GroupProperties() - .getDecryptedGroup() - .accessControl - .addFromInviteLink; - - boolean enabled; - boolean approvalNeeded; - - switch (currentState) { - case UNKNOWN: - case UNSATISFIABLE: - case MEMBER: - enabled = false; - approvalNeeded = false; - break; - case ANY: - enabled = true; - approvalNeeded = false; - break; - case ADMINISTRATOR: - enabled = true; - approvalNeeded = true; - break; - default: throw new AssertionError(); - } - - if (toggleApprovalNeeded) { - approvalNeeded = !approvalNeeded; - } - - if (toggleEnabled) { - enabled = !enabled; - } - - if (approvalNeeded && enabled) { - return GroupManager.GroupLinkState.ENABLED_WITH_APPROVAL; - } else { - if (enabled) { - return GroupManager.GroupLinkState.ENABLED; - } - } - - return GroupManager.GroupLinkState.DISABLED; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.kt new file mode 100644 index 0000000000..6ca84a3a73 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.withContext +import org.signal.chatsettings.screens.sharablegrouplink.GroupLink +import org.signal.core.util.concurrent.SignalDispatchers +import org.signal.core.util.groups.GroupChangeException +import org.signal.core.util.logging.Log +import org.signal.core.util.orNull +import org.signal.storageservice.storage.protos.groups.AccessControl +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl +import org.thoughtcrime.securesms.recipients.Recipient +import java.io.IOException + +/** + * All of the group link reads and writes behind [ShareableGroupLinkViewModel]. + */ +class ShareableGroupLinkRepository( + private val context: Context = AppDependencies.application, + private val groupTable: GroupTable = SignalDatabase.groups +) { + + companion object { + private val TAG = Log.tag(ShareableGroupLinkRepository::class) + } + + /** + * Emits the group's link whenever it changes. A group change always touches the group's recipient, so recipient + * updates drive this. Plenty of unrelated activity touches it too -- an incoming message refreshes it -- hence the + * dedupe. + */ + fun observeGroupLink(groupId: GroupId.V2): Flow { + return flow { + val recipientId = withContext(SignalDispatchers.Default) { Recipient.externalGroupExact(groupId).id } + emitAll(Recipient.observable(recipientId).asFlow()) + }.mapNotNull { recipient -> + withContext(SignalDispatchers.Default) { + groupTable.getGroup(recipient.id).orNull()?.toGroupLink() + } + }.distinctUntilChanged() + } + + /** + * Turns the link on or off, and sets whether joining through it needs an admin's approval. + */ + suspend fun setGroupLinkState(groupId: GroupId.V2, enabled: Boolean, requiresAdminApproval: Boolean): GroupChangeResult = groupChange { + val state = when { + !enabled -> GroupManager.GroupLinkState.DISABLED + requiresAdminApproval -> GroupManager.GroupLinkState.ENABLED_WITH_APPROVAL + else -> GroupManager.GroupLinkState.ENABLED + } + + GroupManager.setGroupLinkEnabledState(context, groupId, state) + } + + /** + * Gives the group a new link, which stops the previous one from working. + */ + suspend fun cycleGroupLinkPassword(groupId: GroupId.V2): GroupChangeResult = groupChange { + GroupManager.cycleGroupLinkPassword(context, groupId) + } + + /** + * Runs a group change, which hits the network, and translates whatever went wrong into something we can show the + * user. + */ + private suspend fun groupChange(block: () -> Unit): GroupChangeResult { + return withContext(SignalDispatchers.IO) { + try { + block() + GroupChangeResult.SUCCESS + } catch (e: GroupChangeException) { + Log.w(TAG, e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } + } + } + + private fun GroupRecord.toGroupLink(): GroupLink { + val selfCanEditSettings = isActive && isAdmin(Recipient.self()) + + if (!hasV2GroupProperties) { + return GroupLink.NONE.copy(selfCanEditSettings = selfCanEditSettings) + } + + val properties = requireV2GroupProperties() + val decryptedGroup = properties.decryptedGroup + + if (decryptedGroup.inviteLinkPassword.size == 0) { + return GroupLink.NONE.copy(selfCanEditSettings = selfCanEditSettings) + } + + return GroupLink( + enabled = isGroupLinkEnabled, + requiresAdminApproval = decryptedGroup.accessControl?.addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR, + url = GroupInviteLinkUrl.forGroup(properties.groupMasterKey, decryptedGroup).url, + selfCanEditSettings = selfCanEditSettings + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java deleted file mode 100644 index 6ec27cd20f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.LiveGroup; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.groups.ui.GroupErrors; -import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; -import org.thoughtcrime.securesms.util.AsynchronousCallback; -import org.thoughtcrime.securesms.util.SingleLiveEvent; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; - -final class ShareableGroupLinkViewModel extends ViewModel { - - private final ShareableGroupLinkRepository repository; - private final LiveData groupLink; - private final SingleLiveEvent toasts; - private final SingleLiveEvent busy; - private final LiveData canEdit; - - private ShareableGroupLinkViewModel(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { - LiveGroup liveGroup = new LiveGroup(groupId); - - this.repository = repository; - this.groupLink = liveGroup.getGroupLink(); - this.canEdit = LiveDataUtil.combineLatest(liveGroup.isSelfAdmin(), liveGroup.isActive(), (admin, active) -> admin && active); - this.toasts = new SingleLiveEvent<>(); - this.busy = new SingleLiveEvent<>(); - } - - LiveData getGroupLink() { - return groupLink; - } - - LiveData getToasts() { - return toasts; - } - - LiveData getBusy() { - return busy; - } - - LiveData getCanEdit() { - return canEdit; - } - - void onToggleGroupLink() { - busy.setValue(true); - repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread() { - @Override - public void onComplete(@Nullable Void result) { - busy.postValue(false); - } - - @Override - public void onError(@Nullable GroupChangeFailureReason error) { - busy.postValue(false); - toasts.postValue(GroupErrors.getUserDisplayMessage(error)); - } - }); - } - - void onToggleApproveMembers() { - busy.setValue(true); - repository.toggleGroupLinkApprovalRequired(new AsynchronousCallback.WorkerThread() { - @Override - public void onComplete(@Nullable Void result) { - busy.postValue(false); - } - - @Override - public void onError(@Nullable GroupChangeFailureReason error) { - busy.postValue(false); - toasts.postValue(GroupErrors.getUserDisplayMessage(error)); - } - }); - } - - void onResetLink() { - busy.setValue(true); - repository.cycleGroupLinkPassword(new AsynchronousCallback.WorkerThread() { - @Override - public void onComplete(@Nullable Void result) { - busy.postValue(false); - } - - @Override - public void onError(@Nullable GroupChangeFailureReason error) { - busy.postValue(false); - toasts.postValue(GroupErrors.getUserDisplayMessage(error)); - } - }); - } - - public static final class Factory implements ViewModelProvider.Factory { - - private final GroupId.V2 groupId; - private final ShareableGroupLinkRepository repository; - - public Factory(@NonNull GroupId.V2 groupId, @NonNull ShareableGroupLinkRepository repository) { - this.groupId = groupId; - this.repository = repository; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection ConstantConditions - return modelClass.cast(new ShareableGroupLinkViewModel(groupId, repository)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.kt new file mode 100644 index 0000000000..3b29480eb3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkEvents +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkState +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkState.Dialog +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.groups.ui.GroupErrors + +/** + * View model behind [ShareableGroupLinkScreen]. + */ +class ShareableGroupLinkViewModel( + private val groupId: GroupId.V2, + private val repository: ShareableGroupLinkRepository = ShareableGroupLinkRepository() +) : EventDrivenViewModel(TAG, false) { + + companion object { + private val TAG = Log.tag(ShareableGroupLinkViewModel::class) + } + + private val _state = MutableStateFlow(ShareableGroupLinkState()) + + val state: StateFlow = _state.asStateFlow() + + init { + repository + .observeGroupLink(groupId) + .onEach { onEvent(ShareableGroupLinkEvents.GroupLinkChanged(it)) } + .launchIn(viewModelScope) + } + + override suspend fun processEvent(event: ShareableGroupLinkEvents) { + when (event) { + is ShareableGroupLinkEvents.GroupLinkChanged -> { + _state.update { it.copy(groupLink = event.groupLink) } + } + + ShareableGroupLinkEvents.GroupLinkToggled -> { + val current = _state.value.groupLink + + applyChange { + repository.setGroupLinkState( + groupId = groupId, + enabled = !current.enabled, + requiresAdminApproval = current.requiresAdminApproval + ) + } + } + + ShareableGroupLinkEvents.AdminApprovalToggled -> { + val current = _state.value.groupLink + + applyChange { + repository.setGroupLinkState( + groupId = groupId, + enabled = current.enabled, + requiresAdminApproval = !current.requiresAdminApproval + ) + } + } + + ShareableGroupLinkEvents.ResetLinkClicked -> { + _state.update { it.copy(dialog = Dialog.ConfirmResetLink) } + } + + ShareableGroupLinkEvents.ResetLinkConfirmed -> { + _state.update { it.copy(dialog = Dialog.None) } + applyChange { repository.cycleGroupLinkPassword(groupId) } + } + + ShareableGroupLinkEvents.DialogDismissed -> { + _state.update { it.copy(dialog = Dialog.None) } + } + + ShareableGroupLinkEvents.SnackbarDismissed -> { + _state.update { it.copy(errorMessage = null) } + } + } + } + + /** + * Runs a group change while the screen shows a spinner over itself. Changes have to be applied one at a time, since + * each toggle is relative to the link's current state. + */ + private suspend fun applyChange(change: suspend () -> GroupChangeResult) { + _state.update { it.copy(busy = true) } + + val result = change() + + _state.update { it.copy(busy = false) } + + if (!result.isSuccess) { + _state.update { it.copy(errorMessage = GroupErrors.getUserDisplayMessage(result.failureReason)) } + } + } +} diff --git a/app/src/main/res/drawable/ic_reset_24_tinted.xml b/app/src/main/res/drawable/ic_reset_24_tinted.xml deleted file mode 100644 index f074c4ccf3..0000000000 --- a/app/src/main/res/drawable/ic_reset_24_tinted.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share_24_tinted.xml b/app/src/main/res/drawable/ic_share_24_tinted.xml deleted file mode 100644 index a629ed71af..0000000000 --- a/app/src/main/res/drawable/ic_share_24_tinted.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6652ce0313..fb0b6ab17a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1533,12 +1533,8 @@ Unknown - Group link - Share - Reset link Require admin approval Require an admin to approve new members joining via the group link. - Are you sure you want to reset the group link? People will no longer be able to join the group using the current link. QR code diff --git a/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModelTest.kt new file mode 100644 index 0000000000..e155d0ffd9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModelTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.chatsettings.screens.sharablegrouplink.GroupLink +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkEvents +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkState.Dialog +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.groups.ui.GroupErrors + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareableGroupLinkViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private val groupId = mockk() + private val repository = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + coEvery { repository.setGroupLinkState(any(), any(), any()) } returns GroupChangeResult.SUCCESS + coEvery { repository.cycleGroupLinkPassword(any()) } returns GroupChangeResult.SUCCESS + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the group's link drives the state`() = runTest(testDispatcher) { + val groupLink = GroupLink(enabled = true, requiresAdminApproval = true, url = GROUP_LINK_URL, selfCanEditSettings = true) + + val viewModel = createViewModel(groupLink) + + assertThat(viewModel.state.value.groupLink).isEqualTo(groupLink) + } + + @Test + fun `turning the link on leaves the approval requirement alone`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = false, requiresAdminApproval = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + + coVerify { repository.setGroupLinkState(groupId, enabled = true, requiresAdminApproval = true) } + } + + @Test + fun `turning the link off leaves the approval requirement alone`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, requiresAdminApproval = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + + coVerify { repository.setGroupLinkState(groupId, enabled = false, requiresAdminApproval = true) } + } + + @Test + fun `requiring admin approval leaves the link on`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, requiresAdminApproval = false, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.AdminApprovalToggled) + advanceUntilIdle() + + coVerify { repository.setGroupLinkState(groupId, enabled = true, requiresAdminApproval = true) } + } + + @Test + fun `no longer requiring admin approval leaves the link on`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, requiresAdminApproval = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.AdminApprovalToggled) + advanceUntilIdle() + + coVerify { repository.setGroupLinkState(groupId, enabled = true, requiresAdminApproval = false) } + } + + @Test + fun `resetting the link warns first`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.ResetLinkClicked) + advanceUntilIdle() + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmResetLink) + coVerify(exactly = 0) { repository.cycleGroupLinkPassword(any()) } + } + + @Test + fun `confirming the warning gives the group a new link`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.ResetLinkClicked) + viewModel.onEvent(ShareableGroupLinkEvents.ResetLinkConfirmed) + advanceUntilIdle() + + coVerify { repository.cycleGroupLinkPassword(groupId) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `dismissing the warning leaves the link alone`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(enabled = true, selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.ResetLinkClicked) + viewModel.onEvent(ShareableGroupLinkEvents.DialogDismissed) + advanceUntilIdle() + + coVerify(exactly = 0) { repository.cycleGroupLinkPassword(any()) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `a change in flight is reported as busy`() = runTest(testDispatcher) { + val inFlight = CompletableDeferred() + coEvery { repository.setGroupLinkState(any(), any(), any()) } coAnswers { inFlight.await() } + + val viewModel = createViewModel(GroupLink.NONE.copy(selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + + assertThat(viewModel.state.value.busy).isTrue() + + inFlight.complete(GroupChangeResult.SUCCESS) + advanceUntilIdle() + + assertThat(viewModel.state.value.busy).isFalse() + } + + @Test + fun `a rejected change surfaces its failure reason`() = runTest(testDispatcher) { + coEvery { repository.setGroupLinkState(any(), any(), any()) } returns GroupChangeResult.failure(GroupChangeFailureReason.NO_RIGHTS) + + val viewModel = createViewModel(GroupLink.NONE.copy(selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + + assertThat(viewModel.state.value.errorMessage).isEqualTo(GroupErrors.getUserDisplayMessage(GroupChangeFailureReason.NO_RIGHTS)) + assertThat(viewModel.state.value.busy).isFalse() + } + + @Test + fun `a failure reason is cleared once its snackbar has been seen`() = runTest(testDispatcher) { + coEvery { repository.setGroupLinkState(any(), any(), any()) } returns GroupChangeResult.failure(GroupChangeFailureReason.NO_RIGHTS) + + val viewModel = createViewModel(GroupLink.NONE.copy(selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + viewModel.onEvent(ShareableGroupLinkEvents.SnackbarDismissed) + advanceUntilIdle() + + assertThat(viewModel.state.value.errorMessage).isNull() + } + + @Test + fun `a change that goes through reports nothing`() = runTest(testDispatcher) { + val viewModel = createViewModel(GroupLink.NONE.copy(selfCanEditSettings = true)) + + viewModel.onEvent(ShareableGroupLinkEvents.GroupLinkToggled) + advanceUntilIdle() + + assertThat(viewModel.state.value.errorMessage).isNull() + } + + /** + * Builds a view model and lets the group's link land before returning it, since every row reads off of it. + */ + private fun TestScope.createViewModel(groupLink: GroupLink): ShareableGroupLinkViewModel { + every { repository.observeGroupLink(groupId) } returns flowOf(groupLink) + + return ShareableGroupLinkViewModel( + groupId = groupId, + repository = repository + ).also { advanceUntilIdle() } + } + + companion object { + private const val GROUP_LINK_URL = "https://signal.group/#CjQKIP_ZZ3Zz" + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 4488511c2e..fc7ba05f50 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -97,6 +97,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)), Recent(icon(R.drawable.symbol_recent_24)), Redo(icon(R.drawable.symbol_redo_24)), + Refresh(icon(R.drawable.symbol_refresh_24)), Save(icon(R.drawable.symbol_save_android_24)), Search(icon(R.drawable.symbol_search_24)), SendFill(icon(R.drawable.symbol_send_fill_24)), diff --git a/app/src/main/res/drawable/symbol_refresh_24.xml b/core/ui/src/main/res/drawable/symbol_refresh_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_refresh_24.xml rename to core/ui/src/main/res/drawable/symbol_refresh_24.xml diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/ErrorSnackbarHost.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/ErrorSnackbarHost.kt new file mode 100644 index 0000000000..0cb5d2fa93 --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/ErrorSnackbarHost.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar + +/** + * Reports a rejected change, and tells the view model once it's been seen so the next rejection can show. + */ +@Composable +internal fun ErrorSnackbarHost( + @StringRes errorMessage: Int?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val hostState = remember { SnackbarHostState() } + val message = errorMessage?.let { stringResource(it) } + + LaunchedEffect(errorMessage) { + if (message != null) { + hostState.showSnackbar(message = message, duration = Snackbars.Duration.LONG) + onDismiss() + } + } + + Snackbars.Host(hostState, modifier = modifier) +} diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/grouppermissions/GroupPermissionsScreen.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/grouppermissions/GroupPermissionsScreen.kt index 2609b9f446..eb8387d176 100644 --- a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/grouppermissions/GroupPermissionsScreen.kt +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/grouppermissions/GroupPermissionsScreen.kt @@ -5,18 +5,15 @@ package org.signal.chatsettings.screens.grouppermissions -import androidx.annotation.StringRes import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewWrapper +import org.signal.chatsettings.ErrorSnackbarHost import org.signal.chatsettings.R import org.signal.chatsettings.screens.grouppermissions.GroupPermissionsState.Dialog import org.signal.core.ui.compose.DayNightPreviews @@ -26,8 +23,6 @@ import org.signal.core.ui.compose.Rows.TextAndLabel import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.SignalPreviewWrapper -import org.signal.core.ui.compose.Snackbars -import org.signal.core.ui.compose.showSnackbar /** Row values, in the order [R.array.GroupPermissionsScreen__editor_labels] declares their labels. */ private const val VALUE_ONLY_ADMINS = "only_admins" @@ -151,28 +146,6 @@ private fun PermissionRow( ) } -/** - * Reports a rejected change, and tells the view model once it's been seen so the next rejection can show. - */ -@Composable -private fun ErrorSnackbarHost( - @StringRes errorMessage: Int?, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val hostState = remember { SnackbarHostState() } - val message = errorMessage?.let { stringResource(it) } - - LaunchedEffect(errorMessage) { - if (message != null) { - hostState.showSnackbar(message = message, duration = Snackbars.Duration.LONG) - onDismiss() - } - } - - Snackbars.Host(hostState, modifier = modifier) -} - @Composable private fun GroupPermissionsDialogs( state: GroupPermissionsState, diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/GroupLink.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/GroupLink.kt new file mode 100644 index 0000000000..abdc591a3f --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/GroupLink.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +/** + * Everything the group link screen reads off of the group's record. A group without a link of its own reads as a + * disabled one with no url. + */ +data class GroupLink( + val enabled: Boolean, + val requiresAdminApproval: Boolean, + val url: String, + /** Whether the user is an admin of an active group, and so allowed to change the link at all. */ + val selfCanEditSettings: Boolean +) { + + companion object { + /** What the screen shows until the group's link arrives: no link, and nothing the user can do about it. */ + val NONE = GroupLink( + enabled = false, + requiresAdminApproval = false, + url = "", + selfCanEditSettings = false + ) + } +} diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkEvents.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkEvents.kt new file mode 100644 index 0000000000..b163a6410e --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkEvents.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +/** + * Represents everything that can happen on the shareable group link screen: what the user does with the link, plus + * the link itself changing underneath us. + */ +sealed interface ShareableGroupLinkEvents { + + /** + * The group's link changed, either because we just changed it or because another admin did. + */ + data class GroupLinkChanged(val groupLink: GroupLink) : ShareableGroupLinkEvents + + /** + * User turned the group link on or off. + */ + data object GroupLinkToggled : ShareableGroupLinkEvents + + /** + * User turned the requirement that an admin approve new members on or off. + */ + data object AdminApprovalToggled : ShareableGroupLinkEvents + + /** + * User asked to reset the link. Anyone holding the current link loses access, so this asks for confirmation first. + */ + data object ResetLinkClicked : ShareableGroupLinkEvents + + /** + * User accepted that resetting the link stops the current one from working. + */ + data object ResetLinkConfirmed : ShareableGroupLinkEvents + + /** + * User dismissed the dialog that was showing. + */ + data object DialogDismissed : ShareableGroupLinkEvents + + /** + * The snackbar reporting a rejected change has come and gone. + */ + data object SnackbarDismissed : ShareableGroupLinkEvents +} diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreen.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreen.kt new file mode 100644 index 0000000000..62c7ac991b --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreen.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewWrapper +import org.signal.chatsettings.ErrorSnackbarHost +import org.signal.chatsettings.R +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkState.Dialog +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.SignalPreviewWrapper +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** A group change usually comes back quickly, so the spinner waits a beat before showing and then stays long enough not to flash. */ +private val BUSY_DELAY = 300.milliseconds +private val BUSY_MINIMUM_DISPLAY = 1.seconds + +/** + * Lets a group's members share its link, and its admins turn that link on and off. + */ +@Composable +fun ShareableGroupLinkScreen( + state: ShareableGroupLinkState, + onEvent: (ShareableGroupLinkEvents) -> Unit, + onShareClick: () -> Unit, + onNavigationClick: () -> Unit +) { + Scaffolds.Settings( + title = stringResource(R.string.ShareableGroupLinkScreen__group_link), + onNavigationClick = onNavigationClick, + navigationIcon = SignalIcons.ArrowStart.imageVector, + navigationContentDescription = stringResource(R.string.ShareableGroupLinkScreen__go_back), + snackbarHost = { + ErrorSnackbarHost( + errorMessage = state.errorMessage, + onDismiss = { onEvent(ShareableGroupLinkEvents.SnackbarDismissed) } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .testTag(ShareableGroupLinkTestTags.CONTENT) + ) { + item { + Rows.ToggleRow( + checked = state.groupLink.enabled, + text = stringResource(R.string.ShareableGroupLinkScreen__group_link), + label = state.groupLink.url.takeIf { state.groupLink.enabled }, + onCheckChanged = { onEvent(ShareableGroupLinkEvents.GroupLinkToggled) }, + modifier = Modifier.testTag(ShareableGroupLinkTestTags.GROUP_LINK_ROW), + enabled = state.groupLink.selfCanEditSettings + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ShareableGroupLinkScreen__share), + icon = SignalIcons.Share.painter, + onClick = onShareClick, + modifier = Modifier.testTag(ShareableGroupLinkTestTags.SHARE_ROW), + enabled = state.groupLink.enabled + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ShareableGroupLinkScreen__reset_link), + icon = SignalIcons.Refresh.painter, + onClick = { onEvent(ShareableGroupLinkEvents.ResetLinkClicked) }, + modifier = Modifier.testTag(ShareableGroupLinkTestTags.RESET_LINK_ROW), + enabled = state.groupLink.enabled && state.groupLink.selfCanEditSettings + ) + } + + item { + Dividers.Default() + } + + item { + Rows.ToggleRow( + checked = state.groupLink.requiresAdminApproval, + text = stringResource(R.string.ShareableGroupLinkScreen__require_admin_approval), + label = stringResource(R.string.ShareableGroupLinkScreen__require_an_admin_to_approve_new_members_joining_via_the_group_link), + onCheckChanged = { onEvent(ShareableGroupLinkEvents.AdminApprovalToggled) }, + modifier = Modifier.testTag(ShareableGroupLinkTestTags.ADMIN_APPROVAL_ROW), + enabled = state.groupLink.enabled && state.groupLink.selfCanEditSettings + ) + } + } + } + + ShareableGroupLinkDialogs( + state = state, + onEvent = onEvent + ) +} + +@Composable +private fun ShareableGroupLinkDialogs( + state: ShareableGroupLinkState, + onEvent: (ShareableGroupLinkEvents) -> Unit +) { + Dialogs.IndeterminateProgressDialog( + visible = state.busy, + delayDuration = BUSY_DELAY, + minimumDisplayDuration = BUSY_MINIMUM_DISPLAY + ) + + when (state.dialog) { + Dialog.ConfirmResetLink -> Dialogs.SimpleAlertDialog( + title = Dialogs.NoTitle, + body = stringResource(R.string.ShareableGroupLinkScreen__are_you_sure_you_want_to_reset_the_group_link), + confirm = stringResource(R.string.ShareableGroupLinkScreen__reset_link), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(ShareableGroupLinkEvents.ResetLinkConfirmed) }, + onDismiss = { onEvent(ShareableGroupLinkEvents.DialogDismissed) } + ) + + Dialog.None -> Unit + } +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun ShareableGroupLinkScreenPreview() { + ShareableGroupLinkScreen( + state = ShareableGroupLinkState( + groupLink = GroupLink.NONE.copy( + enabled = true, + url = "https://signal.group/#CjQKIP_ZZ3Zz", + requiresAdminApproval = true, + selfCanEditSettings = true + ) + ), + onEvent = {}, + onShareClick = {}, + onNavigationClick = {} + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun ShareableGroupLinkScreenDisabledPreview() { + ShareableGroupLinkScreen( + state = ShareableGroupLinkState(groupLink = GroupLink.NONE.copy(selfCanEditSettings = true)), + onEvent = {}, + onShareClick = {}, + onNavigationClick = {} + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun ShareableGroupLinkScreenNonAdminPreview() { + ShareableGroupLinkScreen( + state = ShareableGroupLinkState( + groupLink = GroupLink.NONE.copy( + enabled = true, + url = "https://signal.group/#CjQKIP_ZZ3Zz", + selfCanEditSettings = false + ) + ), + onEvent = {}, + onShareClick = {}, + onNavigationClick = {} + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun ShareableGroupLinkScreenResetLinkDialogPreview() { + ShareableGroupLinkScreen( + state = ShareableGroupLinkState( + groupLink = GroupLink.NONE.copy( + enabled = true, + selfCanEditSettings = true + ), + dialog = Dialog.ConfirmResetLink + ), + onEvent = {}, + onShareClick = {}, + onNavigationClick = {} + ) +} diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkState.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkState.kt new file mode 100644 index 0000000000..a1e975a368 --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +import androidx.annotation.StringRes + +data class ShareableGroupLinkState( + val groupLink: GroupLink = GroupLink.NONE, + /** Whether a change is in flight. Every change here is a group change, so it takes a network round trip. */ + val busy: Boolean = false, + val dialog: Dialog = Dialog.None, + /** + * Why the last change we submitted came back rejected, if it did. The rows render the group's actual link rather + * than what the user asked for, so a rejection needs to say so itself. + */ + @get:StringRes val errorMessage: Int? = null +) { + + sealed interface Dialog { + data object None : Dialog + data object ConfirmResetLink : Dialog + } +} diff --git a/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkTestTags.kt b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkTestTags.kt new file mode 100644 index 0000000000..6d9720db44 --- /dev/null +++ b/feature/chat-settings/src/main/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkTestTags.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +object ShareableGroupLinkTestTags { + const val CONTENT = "content" + + const val GROUP_LINK_ROW = "group_link_row" + const val SHARE_ROW = "share_row" + const val RESET_LINK_ROW = "reset_link_row" + const val ADMIN_APPROVAL_ROW = "admin_approval_row" +} diff --git a/feature/chat-settings/src/main/res/values/strings.xml b/feature/chat-settings/src/main/res/values/strings.xml index 98ca83754f..330ac1946f 100644 --- a/feature/chat-settings/src/main/res/values/strings.xml +++ b/feature/chat-settings/src/main/res/values/strings.xml @@ -22,4 +22,14 @@ Changing this permission to "Only admins" will clear member labels set by non-admins in this group. Change permission + + Group link + Share + + Reset link + Require admin approval + Require an admin to approve new members joining via the group link. + Are you sure you want to reset the group link? People will no longer be able to join the group using the current link. + + Go back diff --git a/feature/chat-settings/src/test/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreenTest.kt b/feature/chat-settings/src/test/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreenTest.kt new file mode 100644 index 0000000000..e375119963 --- /dev/null +++ b/feature/chat-settings/src/test/java/org/signal/chatsettings/screens/sharablegrouplink/ShareableGroupLinkScreenTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.chatsettings.screens.sharablegrouplink + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.chatsettings.screens.sharablegrouplink.ShareableGroupLinkState.Dialog +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.theme.SignalTheme + +/** + * Stands in for a rejected change's message, which the view model resolves out of the app module. Deliberately a string + * the screen never renders itself, so the snackbar assertions cannot match anything else on screen. + */ +private val ERROR_MESSAGE = android.R.string.ok + +/** + * Checks which events the group link rows emit, and that they only emit them when the link is there to act on and the + * user is allowed to act on it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ShareableGroupLinkScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + private val events = mutableListOf() + private var shareClicks = 0 + + @Test + fun `turning the link on emits GroupLinkToggled`() { + setContent(ShareableGroupLinkState(groupLink = GroupLink.NONE.copy(selfCanEditSettings = true))) + + click(ShareableGroupLinkTestTags.GROUP_LINK_ROW) + + assertThat(events).containsExactly(ShareableGroupLinkEvents.GroupLinkToggled) + } + + @Test + fun `turning the link off emits GroupLinkToggled`() { + setContent(enabledLinkState()) + + click(ShareableGroupLinkTestTags.GROUP_LINK_ROW) + + assertThat(events).containsExactly(ShareableGroupLinkEvents.GroupLinkToggled) + } + + @Test + fun `an enabled link shows its url`() { + setContent(enabledLinkState()) + + composeTestRule.onNodeWithText(GROUP_LINK_URL).assertIsDisplayed() + } + + @Test + fun `a disabled link shows no url`() { + setContent(ShareableGroupLinkState(groupLink = GroupLink.NONE.copy(url = GROUP_LINK_URL, selfCanEditSettings = true))) + + composeTestRule.onNodeWithText(GROUP_LINK_URL).assertDoesNotExist() + } + + @Test + fun `sharing the link is left to the caller`() { + setContent(enabledLinkState()) + + click(ShareableGroupLinkTestTags.SHARE_ROW) + + assertThat(shareClicks).isEqualTo(1) + assertThat(events).isEmpty() + } + + @Test + fun `resetting the link emits ResetLinkClicked`() { + setContent(enabledLinkState()) + + click(ShareableGroupLinkTestTags.RESET_LINK_ROW) + + assertThat(events).containsExactly(ShareableGroupLinkEvents.ResetLinkClicked) + } + + @Test + fun `requiring admin approval emits AdminApprovalToggled`() { + setContent(enabledLinkState()) + + click(ShareableGroupLinkTestTags.ADMIN_APPROVAL_ROW) + + assertThat(events).containsExactly(ShareableGroupLinkEvents.AdminApprovalToggled) + } + + @Test + fun `no row does anything while the link is off`() { + setContent(ShareableGroupLinkState(groupLink = GroupLink.NONE.copy(selfCanEditSettings = true))) + + click(ShareableGroupLinkTestTags.SHARE_ROW) + click(ShareableGroupLinkTestTags.RESET_LINK_ROW) + click(ShareableGroupLinkTestTags.ADMIN_APPROVAL_ROW) + + assertThat(shareClicks).isEqualTo(0) + assertThat(events).isEmpty() + } + + @Test + fun `a non-admin can share the link but not change it`() { + setContent(enabledLinkState(selfCanEditSettings = false)) + + click(ShareableGroupLinkTestTags.GROUP_LINK_ROW) + click(ShareableGroupLinkTestTags.RESET_LINK_ROW) + click(ShareableGroupLinkTestTags.ADMIN_APPROVAL_ROW) + click(ShareableGroupLinkTestTags.SHARE_ROW) + + assertThat(shareClicks).isEqualTo(1) + assertThat(events).isEmpty() + } + + @Test + fun `confirming the reset warning emits ResetLinkConfirmed`() { + setContent(enabledLinkState(dialog = Dialog.ConfirmResetLink)) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + assertThat(events).containsExactly( + ShareableGroupLinkEvents.DialogDismissed, + ShareableGroupLinkEvents.ResetLinkConfirmed + ) + } + + @Test + fun `dismissing the reset warning emits DialogDismissed`() { + setContent(enabledLinkState(dialog = Dialog.ConfirmResetLink)) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(events).containsExactly(ShareableGroupLinkEvents.DialogDismissed) + } + + @Test + fun `a rejected change is reported in a snackbar, which emits SnackbarDismissed once it has been seen`() { + setContent(enabledLinkState(errorMessage = ERROR_MESSAGE)) + + composeTestRule.onNodeWithText(getString(ERROR_MESSAGE)).assertIsDisplayed() + + composeTestRule.waitUntil(timeoutMillis = 10_000) { events.isNotEmpty() } + + assertThat(events).containsExactly(ShareableGroupLinkEvents.SnackbarDismissed) + } + + @Test + fun `no snackbar shows while nothing has been rejected`() { + setContent(enabledLinkState()) + + composeTestRule.onNodeWithText(getString(ERROR_MESSAGE)).assertDoesNotExist() + + assertThat(events).isEmpty() + } + + private fun setContent(state: ShareableGroupLinkState) { + composeTestRule.setContent { + SignalTheme { + ShareableGroupLinkScreen( + state = state, + onEvent = { events += it }, + onShareClick = { shareClicks++ }, + onNavigationClick = {} + ) + } + } + } + + private fun enabledLinkState( + selfCanEditSettings: Boolean = true, + dialog: Dialog = Dialog.None, + errorMessage: Int? = null + ): ShareableGroupLinkState { + return ShareableGroupLinkState( + groupLink = GroupLink.NONE.copy( + enabled = true, + url = GROUP_LINK_URL, + selfCanEditSettings = selfCanEditSettings + ), + dialog = dialog, + errorMessage = errorMessage + ) + } + + private fun getString(id: Int): String { + return ApplicationProvider.getApplicationContext().getString(id) + } + + private fun click(tag: String) { + composeTestRule.onNodeWithTag(ShareableGroupLinkTestTags.CONTENT).performScrollToNode(hasTestTag(tag)) + composeTestRule.onNodeWithTag(tag).performClick() + } + + companion object { + private const val GROUP_LINK_URL = "https://signal.group/#CjQKIP_ZZ3Zz" + } +}