diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java deleted file mode 100644 index a9090bbb8e3..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java +++ /dev/null @@ -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 emoji; - private HelpViewModel helpViewModel; - private Spinner categorySpinner; - private ArrayAdapter 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(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.kt new file mode 100644 index 00000000000..dab3b50a250 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.kt @@ -0,0 +1,56 @@ +/* + * 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 { + HelpViewModel( + startCategoryIndex = arguments?.getInt(START_CATEGORY_INDEX, 0) ?: PAYMENT_INDEX, + application = AppDependencies.application + ) + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + HelpScreenContent( + state = state, + onEvent = viewModel::onEvent, + sideEffect = viewModel.sideEffect, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onWhatIsDebugLogClick = { + CommunicationActions.openBrowserLink( + requireContext(), + getString(R.string.HelpFragment__link__debug_info) + ) + }, + onFaqClick = { + CommunicationActions.openBrowserLink( + requireContext(), + getString(R.string.HelpFragment__link__faq) + ) + } + ) + } + + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreen.kt new file mode 100644 index 00000000000..03f3d786b14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreen.kt @@ -0,0 +1,354 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.help + +import android.widget.ImageView +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.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.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.dp +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +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.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil + +@Composable +fun HelpScreenContent( + state: HelpScreenState, + onEvent: (HelpScreenEvents) -> Unit, + sideEffect: Flow, + onNavigationClick: () -> Unit, + onWhatIsDebugLogClick: () -> Unit, + onFaqClick: () -> Unit +) { + val context = LocalContext.current + val categories = stringArrayResource(R.array.HelpFragment__categories_6).toList() + + val snackbarHostState = remember { SnackbarHostState() } + + 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(context.getString(sideEffect.messageRes)) + } + } + } + } + + Scaffolds.Settings( + snackbarHost = { Snackbars.Host(snackbarHostState = snackbarHostState) }, + title = stringResource(R.string.preferences__help), + onNavigationClick = onNavigationClick, + navigationIcon = SignalIcons.ArrowStart.imageVector + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(id = R.string.HelpFragment__contact_us), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + TextField( + 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) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = R.string.HelpFragment__tell_us_why_youre_reaching_out), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + CategoryDropdown( + categories = categories, + selectedIndex = state.categoryIndex, + onCategorySelected = { onEvent(HelpScreenEvents.CategorySelected(it)) } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = R.string.HelpFragment__how_do_you_feel), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + EmojiRatingRow( + selectedFeeling = state.selectedFeeling, + onFeelingSelected = { onEvent(HelpScreenEvents.FeelingSelected(it)) } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + 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 = onWhatIsDebugLogClick) { + Text( + text = stringResource(id = R.string.HelpFragment__whats_this), + color = MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + 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 = { onFaqClick() }, + 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, + selectedIndex: Int, + onCategorySelected: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + 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 +) { + 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 + ) { + AndroidView( + factory = { context -> + EmojiImageView(context).apply { + scaleType = ImageView.ScaleType.FIT_CENTER + } + }, + update = { view -> + view.setImageEmoji(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(), + onNavigationClick = {}, + onWhatIsDebugLogClick = {}, + onFaqClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenEvents.kt new file mode 100644 index 00000000000..fa0e4f49f1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenEvents.kt @@ -0,0 +1,14 @@ +/* + * 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 + object OnNextClick : HelpScreenEvents +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenSideEffects.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenSideEffects.kt new file mode 100644 index 00000000000..e4373a040aa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenSideEffects.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.help + +sealed interface HelpScreenSideEffects { + data class OpenEmail(val subject: String, val body: String) : HelpScreenSideEffects + data class ShowSnackbar(val messageRes: Int) : HelpScreenSideEffects +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenState.kt new file mode 100644 index 00000000000..d368aef54c6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpScreenState.kt @@ -0,0 +1,21 @@ +/* + * 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 isFormValid: Boolean + get() = problemText.length >= MINIMUM_PROBLEM_CHARS && categoryIndex > 0 + + private companion object { + private const val MINIMUM_PROBLEM_CHARS = 10 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java deleted file mode 100644 index 34f01fb0c3c..00000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.java +++ /dev/null @@ -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 problemMeetsLengthRequirements; - private final MutableLiveData categoryIndex; - private final LiveData 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 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 onSubmitClicked(boolean includeDebugLogs) { - MutableLiveData 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 debugLogUrl; - private final boolean isError; - - private SubmitResult(@NonNull Optional debugLogUrl, boolean isError) { - this.debugLogUrl = debugLogUrl; - this.isError = isError; - } - - @NonNull Optional getDebugLogUrl() { - return debugLogUrl; - } - - boolean isError() { - return isError; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.kt new file mode 100644 index 00000000000..08d05343d0f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpViewModel.kt @@ -0,0 +1,128 @@ +/* + * 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(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() + } + } + + 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) { + viewModelScope.launch { + 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 = 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) } + } + } +} diff --git a/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml b/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml deleted file mode 100644 index efc885f3587..00000000000 --- a/app/src/main/res/drawable/help_fragment_emoji_radio_background.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/help_fragment.xml b/app/src/main/res/layout/help_fragment.xml deleted file mode 100644 index 0f28f0aedc4..00000000000 --- a/app/src/main/res/layout/help_fragment.xml +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file