Add new contact us flow.

This commit is contained in:
Alex Hart
2020-03-05 19:05:06 -04:00
committed by Greyson Parrelli
parent f1f505d41c
commit f9de131017
21 changed files with 764 additions and 6 deletions

View File

@@ -29,6 +29,7 @@ import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.Preference;
import org.thoughtcrime.securesms.help.HelpFragment;
import org.thoughtcrime.securesms.preferences.AdvancedPreferenceFragment;
import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment;
import org.thoughtcrime.securesms.preferences.AppearancePreferenceFragment;
@@ -66,6 +67,7 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA
private static final String PREFERENCE_CATEGORY_CHATS = "preference_category_chats";
private static final String PREFERENCE_CATEGORY_STORAGE = "preference_category_storage";
private static final String PREFERENCE_CATEGORY_DEVICES = "preference_category_devices";
private static final String PREFERENCE_CATEGORY_HELP = "preference_category_help";
private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced";
private final DynamicTheme dynamicTheme = new DynamicTheme();
@@ -154,6 +156,8 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA
.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_STORAGE));
this.findPreference(PREFERENCE_CATEGORY_DEVICES)
.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_DEVICES));
this.findPreference(PREFERENCE_CATEGORY_HELP)
.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_HELP));
this.findPreference(PREFERENCE_CATEGORY_ADVANCED)
.setOnPreferenceClickListener(new CategoryClickListener(PREFERENCE_CATEGORY_ADVANCED));
@@ -240,6 +244,9 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA
case PREFERENCE_CATEGORY_ADVANCED:
fragment = new AdvancedPreferenceFragment();
break;
case PREFERENCE_CATEGORY_HELP:
fragment = new HelpFragment();
break;
default:
throw new AssertionError();
}

View File

@@ -0,0 +1,279 @@
package org.thoughtcrime.securesms.help;
import android.content.Intent;
import android.content.pm.LabeledIntent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.Toast;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.annimon.stream.Stream;
import com.dd.CircularProgressButton;
import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.util.IntentUtils;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class HelpFragment extends Fragment {
private EditText problem;
private CheckBox includeDebugLogs;
private View debugLogInfo;
private View faq;
private CircularProgressButton next;
private View toaster;
private List<EmojiImageView> emoji;
private HelpViewModel helpViewModel;
@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();
initializeViews(view);
initializeListeners();
initializeObservers();
}
@Override
public void onResume() {
super.onResume();
((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__help);
cancelSpinning(next);
problem.setEnabled(true);
}
private void initializeViewModels() {
helpViewModel = ViewModelProviders.of(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);
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()));
}
}
private void initializeListeners() {
problem.addTextChangedListener(new AfterTextChanged(e -> helpViewModel.onProblemChanged(e.toString())));
Stream.of(emoji).forEach(view -> view.setOnClickListener(this::handleEmojiClicked));
faq.setOnClickListener(v -> launchFaq());
debugLogInfo.setOnClickListener(v -> launchDebugLogInfo());
next.setOnClickListener(v -> submitForm());
toaster.setOnClickListener(v -> Toast.makeText(requireContext(), R.string.HelpFragment__please_be_as_descriptive_as_possible, Toast.LENGTH_LONG).show());
}
private void initializeObservers() {
//noinspection CodeBlock2Expr
helpViewModel.isFormValid().observe(getViewLifecycleOwner(), isValid -> {
next.setEnabled(isValid);
toaster.setVisibility(isValid ? View.GONE : View.VISIBLE);
});
}
private void handleEmojiClicked(@NonNull View clicked) {
if (clicked.isSelected()) {
clicked.setSelected(false);
} else {
Stream.of(emoji).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() {
setSpinning(next);
problem.setEnabled(false);
helpViewModel.onSubmitClicked(includeDebugLogs.isChecked()).observe(this, 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 = Stream.of(emoji)
.filter(View::isSelected)
.map(view -> Feeling.getByViewId(view.getId()))
.findFirst().orElse(null);
Spanned body = getEmailBody(debugLog, feeling);
Intent intent = new Intent(Intent.ACTION_SENDTO);
intent.setData(Uri.parse("mailto:"));
intent.putExtra(Intent.EXTRA_EMAIL, new String[]{getString(R.string.RegistrationActivity_support_email)});
intent.putExtra(Intent.EXTRA_SUBJECT, getEmailSubject());
intent.putExtra(Intent.EXTRA_TEXT, body.toString());
startActivity(intent);
}
private String getEmailSubject() {
return getString(R.string.HelpFragment__signal_android_support_request);
}
private Spanned getEmailBody(@Nullable String debugLog, @Nullable Feeling feeling) {
SpannableStringBuilder builder = new SpannableStringBuilder();
builder.append(problem.getText().toString());
builder.append("\n\n");
builder.append("--- ");
builder.append(getString(R.string.HelpFragment__support_info));
builder.append(" ---\n");
builder.append(getString(R.string.HelpFragment__subject));
builder.append(" ");
builder.append(getString(R.string.HelpFragment__signal_android_support_request));
builder.append("\n");
builder.append(getString(R.string.HelpFragment__device_info));
builder.append(" ");
builder.append(getDeviceInfo());
builder.append("\n");
builder.append(getString(R.string.HelpFragment__android_version));
builder.append(" ");
builder.append(getAndroidVersion());
builder.append("\n");
builder.append(getString(R.string.HelpFragment__signal_version));
builder.append(" ");
builder.append(getSignalVersion());
builder.append("\n");
builder.append(getString(R.string.HelpFragment__locale));
builder.append(" ");
builder.append(Locale.getDefault().toString());
if (debugLog != null) {
builder.append("\n");
builder.append(getString(R.string.HelpFragment__debug_log));
builder.append(" ");
builder.append(debugLog);
}
if (feeling != null) {
builder.append("\n\n");
builder.append(feeling.getEmojiCode());
builder.append("\n");
builder.append(getString(feeling.getStringId()));
}
return builder;
}
private static CharSequence getDeviceInfo() {
return String.format("%s %s (%s)", Build.MANUFACTURER, Build.MODEL, Build.PRODUCT);
}
private static CharSequence getAndroidVersion() {
return String.format("%s (%s, %s)", Build.VERSION.RELEASE, Build.VERSION.INCREMENTAL, Build.DISPLAY);
}
private static CharSequence getSignalVersion() {
return BuildConfig.VERSION_NAME;
}
private static void setSpinning(@Nullable CircularProgressButton button) {
if (button != null) {
button.setClickable(false);
button.setIndeterminateProgressMode(true);
button.setProgress(50);
}
}
private static void cancelSpinning(@Nullable CircularProgressButton button) {
if (button != null) {
button.setProgress(0);
button.setIndeterminateProgressMode(false);
button.setClickable(true);
}
}
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,79 @@
package org.thoughtcrime.securesms.help;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.logsubmit.LogLine;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository;
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
public class HelpViewModel extends ViewModel {
private static final int MINIMUM_PROBLEM_CHARS = 10;
private MutableLiveData<Boolean> problemMeetsLengthRequirements = new MutableLiveData<>();
private MutableLiveData<Boolean> hasLines = new MutableLiveData<>(false);
private LiveData<Boolean> isFormValid = Transformations.map(new LiveDataPair<>(problemMeetsLengthRequirements, hasLines), this::transformValidationData);
private final SubmitDebugLogRepository submitDebugLogRepository;
private List<LogLine> logLines;
public HelpViewModel() {
submitDebugLogRepository = new SubmitDebugLogRepository();
submitDebugLogRepository.getLogLines(lines -> {
logLines = lines;
hasLines.postValue(true);
});
}
LiveData<Boolean> isFormValid() {
return isFormValid;
}
void onProblemChanged(@NonNull String problem) {
problemMeetsLengthRequirements.setValue(problem.length() >= MINIMUM_PROBLEM_CHARS);
}
LiveData<SubmitResult> onSubmitClicked(boolean includeDebugLogs) {
MutableLiveData<SubmitResult> resultLiveData = new MutableLiveData<>();
if (includeDebugLogs) {
submitDebugLogRepository.submitLog(logLines, result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
} else {
resultLiveData.postValue(new SubmitResult(Optional.absent(), false));
}
return resultLiveData;
}
private boolean transformValidationData(Pair<Boolean, Boolean> validationData) {
return validationData.first() == Boolean.TRUE && validationData.second() == Boolean.TRUE;
}
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

@@ -7,7 +7,7 @@ import com.annimon.stream.Stream;
import java.util.List;
import java.util.regex.Pattern;
interface LogLine {
public interface LogLine {
long getId();
@NonNull String getText();

View File

@@ -41,7 +41,7 @@ import okhttp3.ResponseBody;
* - Create a new {@link LogSection}.
* - Add it to {@link #SECTIONS}. The order of the list is the order the sections are displayed.
*/
class SubmitDebugLogRepository {
public class SubmitDebugLogRepository {
private static final String TAG = Log.tag(SubmitDebugLogRepository.class);
@@ -67,16 +67,16 @@ class SubmitDebugLogRepository {
private final Context context;
private final ExecutorService executor;
SubmitDebugLogRepository() {
public SubmitDebugLogRepository() {
this.context = ApplicationDependencies.getApplication();
this.executor = SignalExecutors.SERIAL;
}
void getLogLines(@NonNull Callback<List<LogLine>> callback) {
public void getLogLines(@NonNull Callback<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getLogLinesInternal()));
}
void submitLog(@NonNull List<LogLine> lines, Callback<Optional<String>> callback) {
public void submitLog(@NonNull List<LogLine> lines, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines)));
}
@@ -209,7 +209,7 @@ class SubmitDebugLogRepository {
return out.toString();
}
interface Callback<E> {
public interface Callback<E> {
void onResult(E result);
}
}

View File

@@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.util;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.LabeledIntent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
@@ -15,4 +19,21 @@ public class IntentUtils {
return resolveInfoList != null && resolveInfoList.size() > 1;
}
/**
* From: <a href="https://stackoverflow.com/a/12328282">https://stackoverflow.com/a/12328282</a>
*/
public static @Nullable LabeledIntent getLabelintent(@NonNull Context context, @NonNull Intent origIntent, int name, int drawable) {
PackageManager pm = context.getPackageManager();
ComponentName launchName = origIntent.resolveActivity(pm);
if (launchName != null) {
Intent resolved = new Intent();
resolved.setComponent(launchName);
resolved.setData(origIntent.getData());
return new LabeledIntent(resolved, context.getPackageName(), name, drawable);
}
return null;
}
}

View File

@@ -1,12 +1,19 @@
package org.thoughtcrime.securesms.util;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.URLSpan;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
public class SpanUtil {