Convert group link settings to compose.

This commit is contained in:
Alex Hart
2026-09-23 16:06:32 -04:00
committed by Michelle Tang
parent ea5df6ad9e
commit d6d4b2abcf
21 changed files with 1061 additions and 407 deletions
@@ -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()) {
@@ -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)
}
}
@@ -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<Void, GroupChangeFailureReason> 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<Void, GroupChangeFailureReason> callback) {
setGroupLinkEnabledState(true, false, callback);
}
void toggleGroupLinkApprovalRequired(@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback) {
setGroupLinkEnabledState(false, true, callback);
}
private void setGroupLinkEnabledState(boolean toggleEnabled,
boolean toggleApprovalNeeded,
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> 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;
}
}
@@ -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<GroupLink> {
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
)
}
}
@@ -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<GroupLinkUrlAndStatus> groupLink;
private final SingleLiveEvent<Integer> toasts;
private final SingleLiveEvent<Boolean> busy;
private final LiveData<Boolean> 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<GroupLinkUrlAndStatus> getGroupLink() {
return groupLink;
}
LiveData<Integer> getToasts() {
return toasts;
}
LiveData<Boolean> getBusy() {
return busy;
}
LiveData<Boolean> getCanEdit() {
return canEdit;
}
void onToggleGroupLink() {
busy.setValue(true);
repository.toggleGroupLinkEnabled(new AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason>() {
@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<Void, GroupChangeFailureReason>() {
@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<Void, GroupChangeFailureReason>() {
@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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ShareableGroupLinkViewModel(groupId, repository));
}
}
}
@@ -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<ShareableGroupLinkEvents>(TAG, false) {
companion object {
private val TAG = Log.tag(ShareableGroupLinkViewModel::class)
}
private val _state = MutableStateFlow(ShareableGroupLinkState())
val state: StateFlow<ShareableGroupLinkState> = _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)) }
}
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/signal_icon_tint_primary"
android:pathData="M12,18.8A6.9,6.9 0,0 1,5.2 12,6.9 6.9,0 0,1 12,5.2a6.5,6.5 0,0 1,4.2 1.6L14,9l6.2,0.8 -0.7,-6.3L17.3,5.7A7.8,7.8 0,0 0,12 3.8a8.2,8.2 0,0 0,0 16.4,8.3 8.3,0 0,0 7.9,-5.7H18.3A7,7 0,0 1,12 18.8Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/signal_icon_tint_primary"
android:pathData="M18,16.2a3,3 0,0 0,-1.9 0.8L8.6,12.8a2.4,2.4 0,0 0,0.2 -0.8c0,-0.2 -0.1,-0.5 -0.1,-0.7L16.1,7a2.7,2.7 0,0 0,1.9 0.8A2.9,2.9 0,0 0,20.8 5a2.8,2.8 0,1 0,-5.6 0c0,0.2 0.1,0.5 0.1,0.7L7.9,10A2.7,2.7 0,0 0,6 9.2,2.9 2.9,0 0,0 3.2,12 2.9,2.9 0,0 0,6 14.8a2.9,2.9 0,0 0,1.8 -0.7l7.5,4.3c0,0.2 -0.1,0.4 -0.1,0.6A2.8,2.8 0,1 0,18 16.2ZM18,3.8A1.2,1.2 0,0 1,19.2 5a1.2,1.2 0,0 1,-2.4 0A1.2,1.2 0,0 1,18 3.8ZM6,13.2a1.2,1.2 0,0 1,0 -2.4,1.2 1.2,0 1,1 0,2.4ZM18,20.2A1.2,1.2 0,1 1,19.2 19,1.2 1.2,0 0,1 18,20.2Z" />
</vector>
-4
View File
@@ -1533,12 +1533,8 @@
<string name="CustomNotificationsDialogFragment__unknown">Unknown</string>
<!-- ShareableGroupLinkDialogFragment -->
<string name="ShareableGroupLinkDialogFragment__group_link">Group link</string>
<string name="ShareableGroupLinkDialogFragment__share">Share</string>
<string name="ShareableGroupLinkDialogFragment__reset_link">Reset link</string>
<string name="ShareableGroupLinkDialogFragment__require_admin_approval">Require admin approval</string>
<string name="ShareableGroupLinkDialogFragment__require_an_admin_to_approve_new_members_joining_via_the_group_link">Require an admin to approve new members joining via the group link.</string>
<string name="ShareableGroupLinkDialogFragment__are_you_sure_you_want_to_reset_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.</string>
<!-- GroupLinkShareQrDialogFragment -->
<string name="GroupLinkShareQrDialogFragment__qr_code">QR code</string>
@@ -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<GroupId.V2>()
private val repository = mockk<ShareableGroupLinkRepository>()
@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<GroupChangeResult>()
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"
}
}
@@ -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)),
@@ -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)
}
@@ -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,
@@ -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
)
}
}
@@ -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
}
@@ -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 = {}
)
}
@@ -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
}
}
@@ -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"
}
@@ -22,4 +22,14 @@
<string name="GroupPermissionsScreen__member_labels_will_be_cleared_body">Changing this permission to &quot;Only admins&quot; will clear member labels set by non-admins in this group.</string>
<!-- Confirm button for changing member label permission after warning. -->
<string name="GroupPermissionsScreen__change_permission">Change permission</string>
<!-- Title of the screen where a group's members share its link and its admins manage it. -->
<string name="ShareableGroupLinkScreen__group_link">Group link</string>
<string name="ShareableGroupLinkScreen__share">Share</string>
<!-- Label of the row that gives the group a new link, and the confirm button of the warning it shows first. -->
<string name="ShareableGroupLinkScreen__reset_link">Reset link</string>
<string name="ShareableGroupLinkScreen__require_admin_approval">Require admin approval</string>
<string name="ShareableGroupLinkScreen__require_an_admin_to_approve_new_members_joining_via_the_group_link">Require an admin to approve new members joining via the group link.</string>
<string name="ShareableGroupLinkScreen__are_you_sure_you_want_to_reset_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.</string>
<!-- Content description of the button that navigates back off of the group link screen. -->
<string name="ShareableGroupLinkScreen__go_back">Go back</string>
</resources>
@@ -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<ShareableGroupLinkEvents>()
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<Application>().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"
}
}