mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Convert group link settings to compose.
This commit is contained in:
@@ -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()) {
|
||||
|
||||
+22
-116
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-124
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-116
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
+213
@@ -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)
|
||||
}
|
||||
+1
-28
@@ -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,
|
||||
|
||||
+29
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
+203
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+15
@@ -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 "Only admins" 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>
|
||||
|
||||
+221
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user