Convert HelpFragment to Compose.

Co-authored-by: Alex Hart <alex@signal.org>
This commit is contained in:
ArseniiS 2026-06-04 12:55:38 -03:00 committed by Cody Henthorne
parent 7cce504f16
commit 595364b522
11 changed files with 666 additions and 623 deletions

View File

@ -1,270 +0,0 @@
package org.thoughtcrime.securesms.help;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AnimationUtils;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.Toast;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import org.signal.core.util.ResourceUtil;
import org.signal.core.ui.logging.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.ArrayList;
import java.util.List;
public class HelpFragment extends LoggingFragment {
public static final String START_CATEGORY_INDEX = "start_category_index";
public static final int PAYMENT_INDEX = 6;
public static final int DONATION_INDEX = 7;
public static final int REMOTE_BACKUPS_INDEX = 8;
private EditText problem;
private CheckBox includeDebugLogs;
private View debugLogInfo;
private View faq;
private CircularProgressMaterialButton next;
private View toaster;
private List<EmojiImageView> emoji;
private HelpViewModel helpViewModel;
private Spinner categorySpinner;
private ArrayAdapter<CharSequence> categoryAdapter;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.help_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
initializeViewModels();
initializeToolbar(view);
initializeViews(view);
initializeListeners();
initializeObservers();
}
@Override
public void onResume() {
super.onResume();
next.cancelSpinning();
problem.setEnabled(true);
}
private void initializeViewModels() {
helpViewModel = new ViewModelProvider(this).get(HelpViewModel.class);
}
private void initializeViews(@NonNull View view) {
problem = view.findViewById(R.id.help_fragment_problem);
includeDebugLogs = view.findViewById(R.id.help_fragment_debug);
debugLogInfo = view.findViewById(R.id.help_fragment_debug_info);
faq = view.findViewById(R.id.help_fragment_faq);
next = view.findViewById(R.id.help_fragment_next);
toaster = view.findViewById(R.id.help_fragment_next_toaster);
categorySpinner = view.findViewById(R.id.help_fragment_category);
emoji = new ArrayList<>(Feeling.values().length);
for (Feeling feeling : Feeling.values()) {
EmojiImageView emojiView = view.findViewById(feeling.getViewId());
emojiView.setImageEmoji(feeling.getEmojiCode());
emoji.add(view.findViewById(feeling.getViewId()));
}
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories_6, android.R.layout.simple_spinner_item);
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
categorySpinner.setAdapter(categoryAdapter);
Bundle args = getArguments();
if (args != null) {
categorySpinner.setSelection(Util.clamp(args.getInt(START_CATEGORY_INDEX, 0), 0, categorySpinner.getCount() - 1));
}
}
private void initializeListeners() {
problem.addTextChangedListener(new AfterTextChanged(e -> helpViewModel.onProblemChanged(e.toString())));
emoji.stream().forEach(view -> view.setOnClickListener(this::handleEmojiClicked));
faq.setOnClickListener(v -> launchFaq());
debugLogInfo.setOnClickListener(v -> launchDebugLogInfo());
next.setOnClickListener(v -> submitForm());
toaster.setOnClickListener(v -> {
if (helpViewModel.getCategoryIndex() == 0) {
categorySpinner.startAnimation(AnimationUtils.loadAnimation(requireContext(), R.anim.shake_horizontal));
}
Toast.makeText(requireContext(), R.string.HelpFragment__please_be_as_descriptive_as_possible, Toast.LENGTH_LONG).show();
});
categorySpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
helpViewModel.onCategorySelected(position);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void initializeObservers() {
//noinspection CodeBlock2Expr
helpViewModel.isFormValid().observe(getViewLifecycleOwner(), isValid -> {
next.setEnabled(isValid);
toaster.setVisibility(isValid ? View.GONE : View.VISIBLE);
});
}
private void initializeToolbar(@NonNull View view) {
Toolbar toolbar = view.findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).navigateUp());
}
private void handleEmojiClicked(@NonNull View clicked) {
if (clicked.isSelected()) {
clicked.setSelected(false);
} else {
emoji.stream().forEach(view -> view.setSelected(false));
clicked.setSelected(true);
}
}
private void launchFaq() {
Uri data = Uri.parse(getString(R.string.HelpFragment__link__faq));
Intent intent = new Intent(Intent.ACTION_VIEW, data);
startActivity(intent);
}
private void launchDebugLogInfo() {
Uri data = Uri.parse(getString(R.string.HelpFragment__link__debug_info));
Intent intent = new Intent(Intent.ACTION_VIEW, data);
startActivity(intent);
}
private void submitForm() {
next.setSpinning();
problem.setEnabled(false);
helpViewModel.onSubmitClicked(includeDebugLogs.isChecked()).observe(getViewLifecycleOwner(), result -> {
if (result.getDebugLogUrl().isPresent()) {
submitFormWithDebugLog(result.getDebugLogUrl().get());
} else if (result.isError()) {
submitFormWithDebugLog(getString(R.string.HelpFragment__could_not_upload_logs));
} else {
submitFormWithDebugLog(null);
}
});
}
private void submitFormWithDebugLog(@Nullable String debugLog) {
Feeling feeling = emoji.stream()
.filter(View::isSelected)
.map(view -> Feeling.getByViewId(view.getId()))
.findFirst().orElse(null);
CommunicationActions.openEmail(requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getEmailSubject(),
getEmailBody(debugLog, feeling));
}
private String getEmailSubject() {
return getString(R.string.HelpFragment__signal_android_support_request);
}
private String getEmailBody(@Nullable String debugLog, @Nullable Feeling feeling) {
StringBuilder suffix = new StringBuilder();
if (debugLog != null) {
suffix.append("\n");
suffix.append(getString(R.string.HelpFragment__debug_log));
suffix.append(" ");
suffix.append(debugLog);
}
if (feeling != null) {
suffix.append("\n\n");
suffix.append(feeling.getEmojiCode());
suffix.append("\n");
suffix.append(getString(feeling.getStringId()));
}
String[] englishCategories = ResourceUtil.getEnglishResources(requireContext()).getStringArray(R.array.HelpFragment__categories_6);
String category = (helpViewModel.getCategoryIndex() >= 0 && helpViewModel.getCategoryIndex() < englishCategories.length) ? englishCategories[helpViewModel.getCategoryIndex()]
: categoryAdapter.getItem(helpViewModel.getCategoryIndex()).toString();
return SupportEmailUtil.generateSupportEmailBody(requireContext(),
R.string.HelpFragment__signal_android_support_request,
" - " + category,
problem.getText().toString() + "\n\n",
suffix.toString());
}
private enum Feeling {
ECSTATIC(R.id.help_fragment_emoji_5, R.string.HelpFragment__emoji_5, "\ud83d\ude00"),
HAPPY(R.id.help_fragment_emoji_4, R.string.HelpFragment__emoji_4, "\ud83d\ude42"),
AMBIVALENT(R.id.help_fragment_emoji_3, R.string.HelpFragment__emoji_3, "\ud83d\ude10"),
UNHAPPY(R.id.help_fragment_emoji_2, R.string.HelpFragment__emoji_2, "\ud83d\ude41"),
ANGRY(R.id.help_fragment_emoji_1, R.string.HelpFragment__emoji_1, "\ud83d\ude20");
private final @IdRes int viewId;
private final @StringRes int stringId;
private final CharSequence emojiCode;
Feeling(@IdRes int viewId, @StringRes int stringId, @NonNull CharSequence emojiCode) {
this.viewId = viewId;
this.stringId = stringId;
this.emojiCode = emojiCode;
}
public @IdRes int getViewId() {
return viewId;
}
public @StringRes int getStringId() {
return stringId;
}
public @NonNull CharSequence getEmojiCode() {
return emojiCode;
}
static Feeling getByViewId(@IdRes int viewId) {
for (Feeling feeling : values()) {
if (feeling.viewId == viewId) {
return feeling;
}
}
throw new AssertionError();
}
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.viewModel
class HelpFragment : ComposeFragment() {
private val viewModel: HelpViewModel by viewModel {
val categoryCount = resources.getStringArray(R.array.HelpFragment__categories_6).size
HelpViewModel(
startCategoryIndex = (arguments?.getInt(START_CATEGORY_INDEX, 0) ?: 0).coerceIn(0, categoryCount - 1),
application = AppDependencies.application
)
}
override fun onResume() {
super.onResume()
viewModel.onScreenResumed()
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
HelpScreenContent(
state = state,
onEvent = { event ->
when (event) {
HelpScreenEvents.NavigationClick -> { requireActivity().onBackPressedDispatcher.onBackPressed() }
HelpScreenEvents.FAQClick -> {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.HelpFragment__link__faq)
)
}
HelpScreenEvents.WhatIsDebugLogClick -> {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.HelpFragment__link__debug_info)
)
}
else -> viewModel.onEvent(event)
}
},
sideEffect = viewModel.sideEffect
)
}
companion object {
const val START_CATEGORY_INDEX = "start_category_index"
const val PAYMENT_INDEX = 6
const val DONATION_INDEX = 7
const val REMOTE_BACKUPS_INDEX = 8
}
}

View File

@ -0,0 +1,401 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.keyframes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CircularProgressWrapper
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiImage
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
@Composable
fun HelpScreenContent(
state: HelpScreenState,
onEvent: (HelpScreenEvents) -> Unit,
sideEffect: Flow<HelpScreenSideEffects>
) {
val context = LocalContext.current
val categories = stringArrayResource(R.array.HelpFragment__categories_6).toList()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val categoryShakeOffset = remember { Animatable(0f) }
LaunchedEffect(Unit) {
sideEffect.collect { sideEffect ->
when (sideEffect) {
is HelpScreenSideEffects.OpenEmail -> {
CommunicationActions.openEmail(
context,
SupportEmailUtil.getSupportEmailAddress(context),
sideEffect.subject,
sideEffect.body
)
}
is HelpScreenSideEffects.ShowSnackbar -> {
snackbarHostState.showSnackbar(sideEffect.getMessage(context))
}
HelpScreenSideEffects.ShakeCategory -> {
scope.launch {
categoryShakeOffset.animateTo(
targetValue = 0f,
animationSpec = keyframes {
durationMillis = 300
0f at 0
-8f at 50
8f at 100
-8f at 150
8f at 200
-8f at 250
0f at 300
}
)
}
}
}
}
}
Scaffolds.Settings(
snackbarHost = { Snackbars.Host(snackbarHostState = snackbarHostState) },
title = stringResource(R.string.preferences__help),
onNavigationClick = { onEvent(HelpScreenEvents.NavigationClick) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.horizontalGutters()
) {
item {
Text(
modifier = Modifier.padding(top = 8.dp, bottom = 12.dp),
text = stringResource(id = R.string.HelpFragment__contact_us),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item {
val isFieldError = state.displayValidationErrors && !state.isTextValid
TextField(
isError = isFieldError,
supportingText = {
if (isFieldError) {
Text(text = pluralStringResource(R.plurals.HelpFragment__must_be_at_least_n_characters, HelpScreenState.MINIMUM_PROBLEM_CHARS, HelpScreenState.MINIMUM_PROBLEM_CHARS))
}
},
value = state.problemText,
onValueChange = { onEvent(HelpScreenEvents.ProblemTextChanged(it)) },
placeholder = {
Text(text = stringResource(id = R.string.HelpFragment__tell_us_whats_going_on))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences
),
maxLines = Int.MAX_VALUE,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 144.dp)
.padding(bottom = 16.dp)
)
}
item {
Text(
modifier = Modifier.padding(bottom = 8.dp),
text = stringResource(id = R.string.HelpFragment__tell_us_why_youre_reaching_out),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item {
CategoryDropdown(
modifier = Modifier
.offset { IntOffset(x = categoryShakeOffset.value.dp.roundToPx(), y = 0) }
.padding(bottom = 16.dp)
.let { modifier ->
if (state.displayValidationErrors && !state.isCategoryValid) {
modifier.border(
width = 1.dp,
color = MaterialTheme.colorScheme.error,
shape = RoundedCornerShape(
topStart = 4.dp,
topEnd = 4.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
)
} else {
modifier
}
},
categories = categories,
selectedIndex = state.categoryIndex,
onCategorySelected = { onEvent(HelpScreenEvents.CategorySelected(it)) }
)
}
item {
Text(
modifier = Modifier.padding(bottom = 12.dp),
text = stringResource(id = R.string.HelpFragment__how_do_you_feel),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
item {
EmojiRatingRow(
modifier = Modifier.padding(bottom = 12.dp),
selectedFeeling = state.selectedFeeling,
onFeelingSelected = { onEvent(HelpScreenEvents.FeelingSelected(it)) }
)
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = state.includeDebugLog,
onCheckedChange = { onEvent(HelpScreenEvents.DebugLogsToggled(it)) }
)
Text(
text = stringResource(id = R.string.HelpFragment__include_debug_log),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = { onEvent(HelpScreenEvents.WhatIsDebugLogClick) }) {
Text(
text = stringResource(id = R.string.HelpFragment__whats_this),
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp, start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f),
text = buildAnnotatedString {
withLink(
link = LinkAnnotation.Clickable(
"view-faq",
linkInteractionListener = { onEvent(HelpScreenEvents.FAQClick) },
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
)
) {
append(stringResource(R.string.HelpFragment__have_you_read_our_faq_yet))
}
}
)
CircularProgressWrapper(
isLoading = state.isSubmitting
) {
Buttons.LargeTonal(
modifier = Modifier.padding(end = 16.dp),
onClick = { onEvent(HelpScreenEvents.OnNextClick) },
enabled = !state.isSubmitting
) {
Text(stringResource(R.string.HelpFragment__next))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryDropdown(
categories: List<String>,
selectedIndex: Int,
onCategorySelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = modifier,
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
TextField(
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true),
value = categories.getOrElse(selectedIndex) { "" },
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
categories.forEachIndexed { index, category ->
DropdownMenuItem(
text = { Text(category) },
onClick = {
onCategorySelected(index)
expanded = false
}
)
}
}
}
}
@Composable
private fun EmojiRatingRow(
selectedFeeling: Feeling?,
onFeelingSelected: (Feeling) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Feeling.entries.forEach { feeling ->
EmojiButton(
feeling = feeling,
isSelected = feeling == selectedFeeling,
onClick = { onFeelingSelected(feeling) }
)
}
}
}
@Composable
private fun EmojiButton(
feeling: Feeling,
isSelected: Boolean,
onClick: () -> Unit
) {
val isDark = isSystemInDarkTheme()
val backgroundColor = if (isSelected) {
if (isDark) Color(0xFF6191f3) else Color(0xFF2C6BED)
} else {
if (isDark) Color(0xFF3b3b3b) else Color(0xFFE9E9E9)
}
Box(
modifier = Modifier
.size(48.dp)
.background(backgroundColor, shape = CircleShape)
.padding(4.dp)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
EmojiImage(
emoji = feeling.emojiCode,
modifier = Modifier.fillMaxSize()
)
}
}
enum class Feeling(val emojiCode: String, val labelRes: Int) {
ECSTATIC(emojiCode = "\ud83d\ude00", labelRes = R.string.HelpFragment__emoji_5),
HAPPY(emojiCode = "\ud83d\ude42", labelRes = R.string.HelpFragment__emoji_4),
AMBIVALENT(emojiCode = "\ud83d\ude10", labelRes = R.string.HelpFragment__emoji_3),
UNHAPPY(emojiCode = "\ud83d\ude41", labelRes = R.string.HelpFragment__emoji_2),
ANGRY(emojiCode = "\ud83d\ude20", labelRes = R.string.HelpFragment__emoji_1)
}
@DayNightPreviews
@Composable
private fun HelpScreenPreview() {
Previews.Preview {
HelpScreenContent(
state = HelpScreenState(),
onEvent = {},
sideEffect = emptyFlow()
)
}
}

View File

@ -0,0 +1,17 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
sealed interface HelpScreenEvents {
data class ProblemTextChanged(val text: String) : HelpScreenEvents
data class CategorySelected(val index: Int) : HelpScreenEvents
data class FeelingSelected(val feeling: Feeling) : HelpScreenEvents
data class DebugLogsToggled(val toggle: Boolean) : HelpScreenEvents
data object OnNextClick : HelpScreenEvents
data object NavigationClick : HelpScreenEvents
data object WhatIsDebugLogClick : HelpScreenEvents
data object FAQClick : HelpScreenEvents
}

View File

@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
import android.content.Context
sealed interface HelpScreenSideEffects {
data class OpenEmail(val subject: String, val body: String) : HelpScreenSideEffects
data class ShowSnackbar(val messageRes: Int) : HelpScreenSideEffects {
fun getMessage(context: Context): String = context.getString(messageRes)
}
data object ShakeCategory : HelpScreenSideEffects
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
data class HelpScreenState(
val problemText: String = "",
val categoryIndex: Int = 0,
val selectedFeeling: Feeling? = null,
val includeDebugLog: Boolean = true,
val isSubmitting: Boolean = false,
val displayValidationErrors: Boolean = false
) {
val isTextValid: Boolean = problemText.length >= MINIMUM_PROBLEM_CHARS
val isCategoryValid: Boolean = categoryIndex > 0
val isFormValid: Boolean = isTextValid && isCategoryValid
companion object {
const val MINIMUM_PROBLEM_CHARS = 10
}
}

View File

@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.help;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Optional;
public class HelpViewModel extends ViewModel {
private static final int MINIMUM_PROBLEM_CHARS = 10;
private final MutableLiveData<Boolean> problemMeetsLengthRequirements;
private final MutableLiveData<Integer> categoryIndex;
private final LiveData<Boolean> isFormValid;
private final SubmitDebugLogRepository submitDebugLogRepository;
public HelpViewModel() {
submitDebugLogRepository = new SubmitDebugLogRepository();
problemMeetsLengthRequirements = new MutableLiveData<>();
categoryIndex = new MutableLiveData<>(0);
isFormValid = LiveDataUtil.combineLatest(problemMeetsLengthRequirements, categoryIndex, (meetsLengthRequirements, index) -> {
return meetsLengthRequirements == Boolean.TRUE && index > 0;
});
}
LiveData<Boolean> isFormValid() {
return isFormValid;
}
void onProblemChanged(@NonNull String problem) {
problemMeetsLengthRequirements.setValue(problem.length() >= MINIMUM_PROBLEM_CHARS);
}
void onCategorySelected(int index) {
this.categoryIndex.setValue(index);
}
int getCategoryIndex() {
return Optional.ofNullable(this.categoryIndex.getValue()).orElse(0);
}
LiveData<SubmitResult> onSubmitClicked(boolean includeDebugLogs) {
MutableLiveData<SubmitResult> resultLiveData = new MutableLiveData<>();
if (includeDebugLogs) {
submitDebugLogRepository.buildAndSubmitLog(result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
} else {
resultLiveData.postValue(new SubmitResult(Optional.empty(), false));
}
return resultLiveData;
}
static class SubmitResult {
private final Optional<String> debugLogUrl;
private final boolean isError;
private SubmitResult(@NonNull Optional<String> debugLogUrl, boolean isError) {
this.debugLogUrl = debugLogUrl;
this.isError = isError;
}
@NonNull Optional<String> getDebugLogUrl() {
return debugLogUrl;
}
boolean isError() {
return isError;
}
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.help
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.ResourceUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
import org.thoughtcrime.securesms.util.SupportEmailUtil
class HelpViewModel(
startCategoryIndex: Int,
application: Application
) : AndroidViewModel(application) {
private val internalState = MutableStateFlow(
HelpScreenState(
categoryIndex = startCategoryIndex
)
)
val state = internalState.asStateFlow()
private val internalSideEffect = Channel<HelpScreenSideEffects>(Channel.BUFFERED)
val sideEffect = internalSideEffect.receiveAsFlow()
private val submitDebugLogRepository = SubmitDebugLogRepository()
fun onEvent(event: HelpScreenEvents) {
when (event) {
is HelpScreenEvents.ProblemTextChanged -> onProblemChanged(event.text)
is HelpScreenEvents.CategorySelected -> onCategorySelected(event.index)
is HelpScreenEvents.FeelingSelected -> onFeelingSelected(event.feeling)
is HelpScreenEvents.DebugLogsToggled -> onDebugLogsToggled(event.toggle)
is HelpScreenEvents.OnNextClick -> onNextClick()
else -> error("Unhandled event: $event")
}
}
fun onScreenResumed() {
internalState.update { it.copy(isSubmitting = false) }
}
private fun onProblemChanged(text: String) {
internalState.update { it.copy(problemText = text) }
}
private fun onCategorySelected(index: Int) {
internalState.update { it.copy(categoryIndex = index) }
}
private fun onFeelingSelected(feeling: Feeling) {
internalState.update { current ->
current.copy(selectedFeeling = if (current.selectedFeeling == feeling) null else feeling)
}
}
private fun onDebugLogsToggled(include: Boolean) {
internalState.update { it.copy(includeDebugLog = include) }
}
private fun onNextClick() {
if (!state.value.isFormValid) {
internalState.update { it.copy(displayValidationErrors = true) }
viewModelScope.launch {
if (state.value.categoryIndex <= 0) {
internalSideEffect.send(HelpScreenSideEffects.ShakeCategory)
}
internalSideEffect.send(HelpScreenSideEffects.ShowSnackbar(R.string.HelpFragment__please_be_as_descriptive_as_possible))
}
return
}
viewModelScope.launch {
if (internalState.value.includeDebugLog) {
internalState.update { it.copy(isSubmitting = true) }
submitDebugLogRepository.buildAndSubmitLog { optionalUrl ->
val debugLogUrl = if (optionalUrl.isPresent) optionalUrl.get()
else application.getString(R.string.HelpFragment__could_not_upload_logs)
dispatchEmail(debugLogUrl)
}
} else {
dispatchEmail(debugLogUrl = null)
}
}
}
private fun dispatchEmail(debugLogUrl: String?) {
val context = application
val state = internalState.value
val englishCategories: Array<String> = ResourceUtil.getEnglishResources(context)
.getStringArray(R.array.HelpFragment__categories_6)
val categoryLabel = englishCategories.getOrElse(state.categoryIndex) { "" }
val suffix = buildString {
if (debugLogUrl != null) {
append("\n")
append(context.getString(R.string.HelpFragment__debug_log))
append(" ")
append(debugLogUrl)
}
state.selectedFeeling?.let { feeling ->
append("\n\n")
append(feeling.emojiCode)
append("\n")
append(context.getString(feeling.labelRes))
}
}
val subject = context.getString(R.string.HelpFragment__signal_android_support_request)
val body = SupportEmailUtil.generateSupportEmailBody(
context,
R.string.HelpFragment__signal_android_support_request,
" - $categoryLabel",
"${state.problemText}\n\n",
suffix
)
viewModelScope.launch {
internalSideEffect.send(HelpScreenSideEffects.OpenEmail(subject = subject, body = body))
internalState.update { it.copy(isSubmitting = false) }
}
}
}

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="oval">
<solid android:color="@color/signal_button_secondary_text" />
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="@color/signal_button_secondary" />
</shape>
</item>
</selector>

View File

@ -1,261 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ScrollView
android:id="@+id/help_fragment_scroller"
android:layout_width="match_parent"
android:layout_height="0dp"
android:fadingEdge="vertical"
android:fillViewport="true"
android:requiresFadingEdge="vertical"
app:layout_constraintBottom_toTopOf="@id/help_fragment_faq"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/symbol_arrow_start_24"
app:title="@string/preferences__help" />
<TextView
android:id="@+id/help_fragment_contact"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:text="@string/HelpFragment__contact_us"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<EditText
android:id="@+id/help_fragment_problem"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:background="@drawable/help_fragment_problem_background"
android:gravity="top"
android:hint="@string/HelpFragment__tell_us_whats_going_on"
android:inputType="textMultiLine"
android:minHeight="144dp"
android:padding="16dp"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_fragment_contact" />
<TextView
android:id="@+id/help_fragment_category_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/HelpFragment__tell_us_why_youre_reaching_out"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_fragment_problem" />
<Spinner
android:id="@+id/help_fragment_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/help_fragment_category_title"
app:layout_constraintStart_toStartOf="parent"/>
<TextView
android:id="@+id/help_fragment_feelings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/HelpFragment__how_do_you_feel"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_fragment_category" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/help_fragment_emoji_5"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="8dp"
android:background="@drawable/help_fragment_emoji_radio_background"
android:button="@null"
android:gravity="center"
android:padding="4dp"
android:scaleType="fitCenter"
app:layout_constraintEnd_toStartOf="@id/help_fragment_emoji_4"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/help_fragment_feelings" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/help_fragment_emoji_4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:background="@drawable/help_fragment_emoji_radio_background"
android:button="@null"
android:gravity="center"
android:scaleType="fitCenter"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_emoji_5"
app:layout_constraintEnd_toStartOf="@id/help_fragment_emoji_3"
app:layout_constraintStart_toEndOf="@id/help_fragment_emoji_5"
app:layout_constraintTop_toTopOf="@id/help_fragment_emoji_5" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/help_fragment_emoji_3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:background="@drawable/help_fragment_emoji_radio_background"
android:button="@null"
android:gravity="center"
android:scaleType="fitCenter"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_emoji_4"
app:layout_constraintEnd_toStartOf="@id/help_fragment_emoji_2"
app:layout_constraintStart_toEndOf="@id/help_fragment_emoji_4"
app:layout_constraintTop_toTopOf="@id/help_fragment_emoji_4" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/help_fragment_emoji_2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:background="@drawable/help_fragment_emoji_radio_background"
android:button="@null"
android:gravity="center"
android:scaleType="fitCenter"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_emoji_3"
app:layout_constraintEnd_toStartOf="@id/help_fragment_emoji_1"
app:layout_constraintStart_toEndOf="@id/help_fragment_emoji_3"
app:layout_constraintTop_toTopOf="@id/help_fragment_emoji_3" />
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/help_fragment_emoji_1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="16dp"
android:background="@drawable/help_fragment_emoji_radio_background"
android:button="@null"
android:gravity="center"
android:scaleType="fitCenter"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_emoji_2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/help_fragment_emoji_2"
app:layout_constraintTop_toTopOf="@id/help_fragment_emoji_2" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/help_fragment_debug"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:checked="true"
android:text="@string/HelpFragment__include_debug_log"
android:textColor="@color/signal_text_secondary"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_debug_info"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/help_fragment_debug_info" />
<com.google.android.material.button.MaterialButton
android:id="@+id/help_fragment_debug_info"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_marginTop="16dp"
android:background="@android:color/transparent"
android:text="@string/HelpFragment__whats_this"
android:textAllCaps="false"
android:textAppearance="@style/Signal.Text.Body"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/help_fragment_debug"
app:layout_constraintTop_toBottomOf="@id/help_fragment_emoji_1" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/help_fragment_faq"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingVertical="8dp"
android:layout_marginBottom="16dp"
android:text="@string/HelpFragment__have_you_read_our_faq_yet"
android:textAllCaps="false"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/help_fragment_next"
app:layout_constraintStart_toStartOf="parent" />
<org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
android:id="@+id/help_fragment_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:enabled="false"
app:circularProgressMaterialButton__label="@string/HelpFragment__next"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_faq"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/help_fragment_faq"
app:layout_constraintTop_toTopOf="@id/help_fragment_faq" />
<View
android:id="@+id/help_fragment_next_toaster"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="16dp"
android:enabled="true"
app:layout_constraintBottom_toBottomOf="@id/help_fragment_faq"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/help_fragment_faq"
app:layout_constraintTop_toTopOf="@id/help_fragment_faq" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4101,6 +4101,11 @@
<string name="HelpFragment__debug_log" translatable="false">Debug Log:</string>
<string name="HelpFragment__could_not_upload_logs">Could not upload logs</string>
<string name="HelpFragment__please_be_as_descriptive_as_possible">Please be as descriptive as possible to help us understand the issue.</string>
<!-- Error shown under the "tell us what's going on" field when the entered description is shorter than the required minimum length. The placeholder is the minimum number of characters. -->
<plurals name="HelpFragment__must_be_at_least_n_characters">
<item quantity="one">Must be at least %1$d character</item>
<item quantity="other">Must be at least %1$d characters</item>
</plurals>
<string-array name="HelpFragment__categories_6">
<item>Please select an option</item>
<item>Something\'s Not Working</item>