Add Research Megaphone.

This commit is contained in:
Cody Henthorne 2020-09-18 17:32:56 -04:00 committed by Greyson Parrelli
parent 9dbb77c10a
commit ca442970a3
28 changed files with 685 additions and 67 deletions

View file

@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.components;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ThemeUtil;
/**
* Base dialog fragment for rendering as a full screen dialog with animation
* transitions.
*/
public abstract class FullScreenDialogFragment extends DialogFragment {
protected Toolbar toolbar;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme_FullScreenDialog
: R.style.TextSecure_LightTheme_FullScreenDialog);
}
@Override
public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
toolbar.setTitle(getTitle());
toolbar.setNavigationOnClickListener(v -> onNavigateUp());
return view;
}
protected void onNavigateUp() {
dismissAllowingStateLoss();
}
protected abstract @StringRes int getTitle();
protected abstract @LayoutRes int getDialogLayoutResource();
}

View file

@ -12,8 +12,6 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -31,12 +29,8 @@ public class MentionsPickerViewModel extends ViewModel {
private final MutableLiveData<LiveRecipient> liveRecipient; private final MutableLiveData<LiveRecipient> liveRecipient;
private final MutableLiveData<Query> liveQuery; private final MutableLiveData<Query> liveQuery;
private final MutableLiveData<Boolean> isShowing; private final MutableLiveData<Boolean> isShowing;
private final MegaphoneRepository megaphoneRepository;
MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository, MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) {
@NonNull MegaphoneRepository megaphoneRepository)
{
this.megaphoneRepository = megaphoneRepository;
this.liveRecipient = new MutableLiveData<>(); this.liveRecipient = new MutableLiveData<>();
this.liveQuery = new MutableLiveData<>(); this.liveQuery = new MutableLiveData<>();
this.selectedRecipient = new SingleLiveEvent<>(); this.selectedRecipient = new SingleLiveEvent<>();
@ -56,7 +50,6 @@ public class MentionsPickerViewModel extends ViewModel {
void onSelectionChange(@NonNull Recipient recipient) { void onSelectionChange(@NonNull Recipient recipient) {
selectedRecipient.setValue(recipient); selectedRecipient.setValue(recipient);
megaphoneRepository.markFinished(Megaphones.Event.MENTIONS);
} }
void setIsShowing(boolean isShowing) { void setIsShowing(boolean isShowing) {
@ -119,8 +112,7 @@ public class MentionsPickerViewModel extends ViewModel {
@Override @Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) { public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions //noinspection ConstantConditions
return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()), return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication())));
ApplicationDependencies.getMegaphoneRepository()));
} }
} }
} }

View file

@ -55,6 +55,7 @@ import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat; import androidx.appcompat.widget.TooltipCompat;
import androidx.core.content.res.ResourcesCompat; import androidx.core.content.res.ResourcesCompat;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleOwner;
@ -423,6 +424,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
viewModel.onMegaphoneCompleted(event); viewModel.onMegaphoneCompleted(event);
} }
@Override
public void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment) {
dialogFragment.show(getChildFragmentManager(), "megaphone_dialog");
}
private void onReminderAction(@IdRes int reminderActionId) { private void onReminderAction(@IdRes int reminderActionId) {
if (reminderActionId == R.id.reminder_action_update_now) { if (reminderActionId == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()); PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());

View file

@ -18,7 +18,7 @@ public class BasicMegaphoneView extends FrameLayout {
private TextView titleText; private TextView titleText;
private TextView bodyText; private TextView bodyText;
private Button actionButton; private Button actionButton;
private Button snoozeButton; private Button secondaryButton;
private Megaphone megaphone; private Megaphone megaphone;
private MegaphoneActionController megaphoneListener; private MegaphoneActionController megaphoneListener;
@ -40,7 +40,7 @@ public class BasicMegaphoneView extends FrameLayout {
this.titleText = findViewById(R.id.basic_megaphone_title); this.titleText = findViewById(R.id.basic_megaphone_title);
this.bodyText = findViewById(R.id.basic_megaphone_body); this.bodyText = findViewById(R.id.basic_megaphone_body);
this.actionButton = findViewById(R.id.basic_megaphone_action); this.actionButton = findViewById(R.id.basic_megaphone_action);
this.snoozeButton = findViewById(R.id.basic_megaphone_snooze); this.secondaryButton = findViewById(R.id.basic_megaphone_secondary);
} }
@Override @Override
@ -89,9 +89,11 @@ public class BasicMegaphoneView extends FrameLayout {
actionButton.setVisibility(GONE); actionButton.setVisibility(GONE);
} }
if (megaphone.canSnooze() || megaphone.hasSecondaryButton()) {
secondaryButton.setVisibility(VISIBLE);
if (megaphone.canSnooze()) { if (megaphone.canSnooze()) {
snoozeButton.setVisibility(VISIBLE); secondaryButton.setOnClickListener(v -> {
snoozeButton.setOnClickListener(v -> {
megaphoneListener.onMegaphoneSnooze(megaphone.getEvent()); megaphoneListener.onMegaphoneSnooze(megaphone.getEvent());
if (megaphone.getSnoozeListener() != null) { if (megaphone.getSnoozeListener() != null) {
@ -99,7 +101,15 @@ public class BasicMegaphoneView extends FrameLayout {
} }
}); });
} else { } else {
snoozeButton.setVisibility(GONE); secondaryButton.setText(megaphone.getSecondaryButtonText());
secondaryButton.setOnClickListener(v -> {
if (megaphone.getSecondaryButtonClickListener() != null) {
megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener);
}
});
}
} else {
secondaryButton.setVisibility(GONE);
} }
} }
} }

View file

@ -28,6 +28,8 @@ public class Megaphone {
private final int buttonTextRes; private final int buttonTextRes;
private final EventListener buttonListener; private final EventListener buttonListener;
private final EventListener snoozeListener; private final EventListener snoozeListener;
private final int secondaryButtonTextRes;
private final EventListener secondaryButtonListener;
private final EventListener onVisibleListener; private final EventListener onVisibleListener;
private Megaphone(@NonNull Builder builder) { private Megaphone(@NonNull Builder builder) {
@ -41,6 +43,8 @@ public class Megaphone {
this.buttonTextRes = builder.buttonTextRes; this.buttonTextRes = builder.buttonTextRes;
this.buttonListener = builder.buttonListener; this.buttonListener = builder.buttonListener;
this.snoozeListener = builder.snoozeListener; this.snoozeListener = builder.snoozeListener;
this.secondaryButtonTextRes = builder.secondaryButtonTextRes;
this.secondaryButtonListener = builder.secondaryButtonListener;
this.onVisibleListener = builder.onVisibleListener; this.onVisibleListener = builder.onVisibleListener;
} }
@ -88,6 +92,18 @@ public class Megaphone {
return snoozeListener; return snoozeListener;
} }
public @StringRes int getSecondaryButtonText() {
return secondaryButtonTextRes;
}
public boolean hasSecondaryButton() {
return secondaryButtonTextRes != 0;
}
public @Nullable EventListener getSecondaryButtonClickListener() {
return secondaryButtonListener;
}
public @Nullable EventListener getOnVisibleListener() { public @Nullable EventListener getOnVisibleListener() {
return onVisibleListener; return onVisibleListener;
} }
@ -105,6 +121,8 @@ public class Megaphone {
private int buttonTextRes; private int buttonTextRes;
private EventListener buttonListener; private EventListener buttonListener;
private EventListener snoozeListener; private EventListener snoozeListener;
private int secondaryButtonTextRes;
private EventListener secondaryButtonListener;
private EventListener onVisibleListener; private EventListener onVisibleListener;
@ -159,6 +177,12 @@ public class Megaphone {
return this; return this;
} }
public @NonNull Builder setSecondaryButton(@StringRes int secondaryButtonTextRes, @NonNull EventListener listener) {
this.secondaryButtonTextRes = secondaryButtonTextRes;
this.secondaryButtonListener = listener;
return this;
}
public @NonNull Builder setOnVisibleListener(@Nullable EventListener listener) { public @NonNull Builder setOnVisibleListener(@Nullable EventListener listener) {
this.onVisibleListener = listener; this.onVisibleListener = listener;
return this; return this;

View file

@ -5,6 +5,7 @@ import android.content.Intent;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.fragment.app.DialogFragment;
public interface MegaphoneActionController { public interface MegaphoneActionController {
/** /**
@ -36,4 +37,9 @@ public interface MegaphoneActionController {
* Called when a megaphone completed its goal. * Called when a megaphone completed its goal.
*/ */
void onMegaphoneCompleted(@NonNull Megaphones.Event event); void onMegaphoneCompleted(@NonNull Megaphones.Event event);
/**
* When a megaphone wnats to show a dialog fragment.
*/
void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment);
} }

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.megaphone;
import android.content.Context; import android.content.Context;
import androidx.annotation.AnyThread; import androidx.annotation.AnyThread;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread; import androidx.annotation.WorkerThread;
@ -53,7 +52,7 @@ public class MegaphoneRepository {
executor.execute(() -> { executor.execute(() -> {
database.markFinished(Event.REACTIONS); database.markFinished(Event.REACTIONS);
database.markFinished(Event.MESSAGE_REQUESTS); database.markFinished(Event.MESSAGE_REQUESTS);
database.markFinished(Event.MENTIONS); database.markFinished(Event.RESEARCH);
resetDatabaseCache(); resetDatabaseCache();
}); });
} }

View file

@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivit
import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ResearchMegaphone;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -85,9 +86,9 @@ public final class Megaphones {
put(Event.PINS_FOR_ALL, new PinsForAllSchedule()); put(Event.PINS_FOR_ALL, new PinsForAllSchedule());
put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER); put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER);
put(Event.MENTIONS, shouldShowMentionsMegaphone() ? ALWAYS : NEVER);
put(Event.LINK_PREVIEWS, shouldShowLinkPreviewsMegaphone(context) ? ALWAYS : NEVER); put(Event.LINK_PREVIEWS, shouldShowLinkPreviewsMegaphone(context) ? ALWAYS : NEVER);
put(Event.CLIENT_DEPRECATED, SignalStore.misc().isClientDeprecated() ? ALWAYS : NEVER); put(Event.CLIENT_DEPRECATED, SignalStore.misc().isClientDeprecated() ? ALWAYS : NEVER);
put(Event.RESEARCH, shouldShowResearchMegaphone() ? ShowForDurationSchedule.showForDays(7) : NEVER);
}}; }};
} }
@ -101,12 +102,12 @@ public final class Megaphones {
return buildPinReminderMegaphone(context); return buildPinReminderMegaphone(context);
case MESSAGE_REQUESTS: case MESSAGE_REQUESTS:
return buildMessageRequestsMegaphone(context); return buildMessageRequestsMegaphone(context);
case MENTIONS:
return buildMentionsMegaphone();
case LINK_PREVIEWS: case LINK_PREVIEWS:
return buildLinkPreviewsMegaphone(); return buildLinkPreviewsMegaphone();
case CLIENT_DEPRECATED: case CLIENT_DEPRECATED:
return buildClientDeprecatedMegaphone(context); return buildClientDeprecatedMegaphone(context);
case RESEARCH:
return buildResearchMegaphone(context);
default: default:
throw new IllegalArgumentException("Event not handled!"); throw new IllegalArgumentException("Event not handled!");
} }
@ -189,14 +190,6 @@ public final class Megaphones {
.build(); .build();
} }
private static Megaphone buildMentionsMegaphone() {
return new Megaphone.Builder(Event.MENTIONS, Megaphone.Style.POPUP)
.setTitle(R.string.MentionsMegaphone__introducing_mentions)
.setBody(R.string.MentionsMegaphone__get_someones_attention_in_a_group_by_typing)
.setImage(R.drawable.mention_megaphone)
.build();
}
private static @NonNull Megaphone buildLinkPreviewsMegaphone() { private static @NonNull Megaphone buildLinkPreviewsMegaphone() {
return new Megaphone.Builder(Event.LINK_PREVIEWS, Megaphone.Style.LINK_PREVIEWS) return new Megaphone.Builder(Event.LINK_PREVIEWS, Megaphone.Style.LINK_PREVIEWS)
.setPriority(Megaphone.Priority.HIGH) .setPriority(Megaphone.Priority.HIGH)
@ -207,9 +200,22 @@ public final class Megaphones {
return new Megaphone.Builder(Event.CLIENT_DEPRECATED, Megaphone.Style.FULLSCREEN) return new Megaphone.Builder(Event.CLIENT_DEPRECATED, Megaphone.Style.FULLSCREEN)
.disableSnooze() .disableSnooze()
.setPriority(Megaphone.Priority.HIGH) .setPriority(Megaphone.Priority.HIGH)
.setOnVisibleListener((megaphone, listener) -> { .setOnVisibleListener((megaphone, listener) -> listener.onMegaphoneNavigationRequested(new Intent(context, ClientDeprecatedActivity.class)))
listener.onMegaphoneNavigationRequested(new Intent(context, ClientDeprecatedActivity.class)); .build();
}
private static @NonNull Megaphone buildResearchMegaphone(@NonNull Context context) {
return new Megaphone.Builder(Event.RESEARCH, Megaphone.Style.BASIC)
.disableSnooze()
.setTitle(R.string.ResearchMegaphone_tell_signal_what_you_think)
.setBody(R.string.ResearchMegaphone_to_make_signal_the_best_messaging_app_on_the_planet)
.setImage(R.drawable.ic_research_megaphone)
.setActionButton(R.string.ResearchMegaphone_learn_more, (megaphone, controller) -> {
controller.onMegaphoneCompleted(megaphone.getEvent());
controller.onMegaphoneDialogFragmentRequested(new ResearchMegaphoneDialog());
}) })
.setSecondaryButton(R.string.ResearchMegaphone_dismiss, (megaphone, controller) -> controller.onMegaphoneCompleted(megaphone.getEvent()))
.setPriority(Megaphone.Priority.DEFAULT)
.build(); .build();
} }
@ -217,9 +223,8 @@ public final class Megaphones {
return Recipient.self().getProfileName() == ProfileName.EMPTY; return Recipient.self().getProfileName() == ProfileName.EMPTY;
} }
private static boolean shouldShowMentionsMegaphone() { private static boolean shouldShowResearchMegaphone() {
return false; return ResearchMegaphone.isInResearchMegaphone();
// return FeatureFlags.mentions();
} }
private static boolean shouldShowLinkPreviewsMegaphone(@NonNull Context context) { private static boolean shouldShowLinkPreviewsMegaphone(@NonNull Context context) {
@ -231,9 +236,9 @@ public final class Megaphones {
PINS_FOR_ALL("pins_for_all"), PINS_FOR_ALL("pins_for_all"),
PIN_REMINDER("pin_reminder"), PIN_REMINDER("pin_reminder"),
MESSAGE_REQUESTS("message_requests"), MESSAGE_REQUESTS("message_requests"),
MENTIONS("mentions"),
LINK_PREVIEWS("link_previews"), LINK_PREVIEWS("link_previews"),
CLIENT_DEPRECATED("client_deprecated"); CLIENT_DEPRECATED("client_deprecated"),
RESEARCH("research");
private final String key; private final String key;

View file

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.megaphone;
import android.os.Bundle;
import android.text.Html;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.FullScreenDialogFragment;
import org.thoughtcrime.securesms.util.CommunicationActions;
public class ResearchMegaphoneDialog extends FullScreenDialogFragment {
private static final String SURVEY_URL = "https://surveys.signalusers.org/s3";
@Override
public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = super.onCreateView(inflater, container, savedInstanceState);
TextView content = view.findViewById(R.id.research_megaphone_content);
content.setText(Html.fromHtml(requireContext().getString(R.string.ResearchMegaphoneDialog_we_believe_in_privacy)));
view.findViewById(R.id.research_megaphone_dialog_take_the_survey)
.setOnClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), SURVEY_URL));
view.findViewById(R.id.research_megaphone_dialog_no_thanks)
.setOnClickListener(v -> dismissAllowingStateLoss());
return view;
}
@Override
protected @StringRes int getTitle() {
return R.string.ResearchMegaphoneDialog_signal_research;
}
@Override
protected int getDialogLayoutResource() {
return R.layout.research_megaphone_dialog;
}
}

View file

@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.megaphone;
import java.util.concurrent.TimeUnit;
/**
* Megaphone schedule that will always show for some duration after the first
* time the user sees it.
*/
public class ShowForDurationSchedule implements MegaphoneSchedule {
private final long duration;
public static MegaphoneSchedule showForDays(int days) {
return new ShowForDurationSchedule(TimeUnit.DAYS.toMillis(days));
}
public ShowForDurationSchedule(long duration) {
this.duration = duration;
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) {
return firstVisible == 0 || currentTime < firstVisible + duration;
}
}

View file

@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.UUID;
/**
* Logic to bucket a user for a given feature flag based on their UUID.
*/
public final class BucketingUtil {
private BucketingUtil() {}
/**
* Calculate a user bucket for a given feature flag, uuid, and part per modulus.
*
* @param key Feature flag key (e.g., "research.megaphone.1")
* @param uuid Current user's UUID (see {@link Recipient#getUuid()})
* @param modulus Drives the bucketing parts per N (e.g., passing 1,000,000 indicates bucketing into parts per million)
*/
public static long bucket(@NonNull String key, @NonNull UUID uuid, long modulus) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
digest.update(key.getBytes());
digest.update(".".getBytes());
ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
byteBuffer.order(ByteOrder.BIG_ENDIAN);
byteBuffer.putLong(uuid.getMostSignificantBits());
byteBuffer.putLong(uuid.getLeastSignificantBits());
digest.update(byteBuffer.array());
return new BigInteger(Arrays.copyOfRange(digest.digest(), 0, 8)).mod(BigInteger.valueOf(modulus)).longValue();
}
}

View file

@ -7,6 +7,9 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.google.android.collect.Sets; import com.google.android.collect.Sets;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@ -17,7 +20,10 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
@ -65,6 +71,7 @@ public final class FeatureFlags {
private static final String VERIFY_V2 = "android.verifyV2"; private static final String VERIFY_V2 = "android.verifyV2";
private static final String PHONE_NUMBER_PRIVACY_VERSION = "android.phoneNumberPrivacyVersion"; private static final String PHONE_NUMBER_PRIVACY_VERSION = "android.phoneNumberPrivacyVersion";
private static final String CLIENT_EXPIRATION = "android.clientExpiration"; private static final String CLIENT_EXPIRATION = "android.clientExpiration";
public static final String RESEARCH_MEGAPHONE_1 = "research.megaphone.1";
/** /**
* We will only store remote values for flags in this set. If you want a flag to be controllable * We will only store remote values for flags in this set. If you want a flag to be controllable
@ -83,7 +90,8 @@ public final class FeatureFlags {
USERNAMES, USERNAMES,
MENTIONS, MENTIONS,
VERIFY_V2, VERIFY_V2,
CLIENT_EXPIRATION CLIENT_EXPIRATION,
RESEARCH_MEGAPHONE_1
); );
/** /**
@ -283,6 +291,11 @@ public final class FeatureFlags {
return getString(CLIENT_EXPIRATION, null); return getString(CLIENT_EXPIRATION, null);
} }
/** The raw research megaphone CSV string */
public static String researchMegaphone() {
return getString(RESEARCH_MEGAPHONE_1, "");
}
/** /**
* Whether the user can choose phone number privacy settings, and; * Whether the user can choose phone number privacy settings, and;
* Whether to fetch and store the secondary certificate * Whether to fetch and store the secondary certificate

View file

@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.HashMap;
import java.util.Map;
/**
* Parses a comma-separated list of country codes colon-separated from how many buckets out of 1 million
* should be enabled to see this megaphone in that country code. At the end of the list, an optional
* element saying how many buckets out of a million should be enabled for all countries not listed previously
* in the list. For example, "1:20000,*:40000" would mean 2% of the NANPA phone numbers and 4% of the rest of
* the world should see the megaphone.
*/
public final class ResearchMegaphone {
private static final String TAG = Log.tag(ResearchMegaphone.class);
private static final String COUNTRY_WILDCARD = "*";
/**
* In research megaphone group for given country code
*/
public static boolean isInResearchMegaphone() {
Map<String, Integer> countryCountEnabled = parseCountryCounts(FeatureFlags.researchMegaphone());
Recipient self = Recipient.self();
if (countryCountEnabled.isEmpty() || !self.getE164().isPresent() || !self.getUuid().isPresent()) {
return false;
}
long countEnabled = determineCountEnabled(countryCountEnabled, self.getE164().or(""));
long currentUserBucket = BucketingUtil.bucket(FeatureFlags.RESEARCH_MEGAPHONE_1, self.requireUuid(), 1_000_000);
return countEnabled > currentUserBucket;
}
@VisibleForTesting
static @NonNull Map<String, Integer> parseCountryCounts(@NonNull String buckets) {
Map<String, Integer> countryCountEnabled = new HashMap<>();
for (String bucket : buckets.split(",")) {
String[] parts = bucket.split(":");
if (parts.length == 2 && !parts[0].isEmpty()) {
countryCountEnabled.put(parts[0], Util.parseInt(parts[1], 0));
}
}
return countryCountEnabled;
}
@VisibleForTesting
static long determineCountEnabled(@NonNull Map<String, Integer> countryCountEnabled, @NonNull String e164) {
Integer countEnabled = countryCountEnabled.get(COUNTRY_WILDCARD);
try {
String countryCode = String.valueOf(PhoneNumberUtil.getInstance().parse(e164, "").getCountryCode());
if (countryCountEnabled.containsKey(countryCode)) {
countEnabled = countryCountEnabled.get(countryCode);
}
} catch (NumberParseException e) {
Log.d(TAG, "Unable to determine country code for bucketing.");
return 0;
}
return countEnabled != null ? countEnabled : 0;
}
}

View file

@ -664,6 +664,14 @@ public class Util {
} }
} }
public static int parseInt(String integer, int defaultValue) {
try {
return Integer.parseInt(integer);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/** /**
* Appends the stack trace of the provided throwable onto the provided primary exception. This is * Appends the stack trace of the provided throwable onto the provided primary exception. This is
* useful for when exceptions are thrown inside of asynchronous systems (like runnables in an * useful for when exceptions are thrown inside of asynchronous systems (like runnables in an

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<clip-path
android:pathData="M6,0L54,0A6,6 0,0 1,60 6L60,54A6,6 0,0 1,54 60L6,60A6,6 0,0 1,0 54L0,6A6,6 0,0 1,6 0z"/>
<path
android:pathData="M6,0L54,0A6,6 0,0 1,60 6L60,54A6,6 0,0 1,54 60L6,60A6,6 0,0 1,0 54L0,6A6,6 0,0 1,6 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M0,0h60v60h-60z"
android:fillColor="#DFE9FD"/>
<path
android:pathData="M-6,13L5,13A8,8 0,0 1,13 21L13,38A8,8 0,0 1,5 46L-6,46A8,8 0,0 1,-14 38L-14,21A8,8 0,0 1,-6 13z"
android:fillColor="#2C6BED"/>
<path
android:pathData="M28,13L83,13A8,8 0,0 1,91 21L91,38A8,8 0,0 1,83 46L28,46A8,8 0,0 1,20 38L20,21A8,8 0,0 1,28 13z"
android:fillColor="#6191F3"/>
<path
android:pathData="M33.81,51.3213L30.0808,24.8698C30.0493,24.6463 30.2956,24.4897 30.4846,24.6131L53.8863,39.8865C54.0758,40.0102 54.0308,40.2996 53.8126,40.3598L46.2009,42.4617C46.031,42.5086 45.9546,42.7061 46.0486,42.8552L52.9586,53.8078C53.0337,53.9269 53.0017,54.084 52.8859,54.1641L48.3603,57.2976C48.2386,57.3818 48.0714,57.349 47.9905,57.225L40.4311,45.6269C40.3393,45.4861 40.1409,45.4664 40.0233,45.5864L34.2579,51.4685C34.1053,51.6242 33.8404,51.5371 33.81,51.3213Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M33.81,51.3213L30.0808,24.8698C30.0493,24.6463 30.2956,24.4897 30.4846,24.6131L53.8863,39.8865C54.0758,40.0102 54.0308,40.2996 53.8126,40.3598L46.2009,42.4617C46.031,42.5086 45.9546,42.7061 46.0486,42.8552L52.9586,53.8078C53.0337,53.9269 53.0017,54.084 52.8859,54.1641L48.3603,57.2976C48.2386,57.3818 48.0714,57.349 47.9905,57.225L40.4311,45.6269C40.3393,45.4861 40.1409,45.4664 40.0233,45.5864L34.2579,51.4685C34.1053,51.6242 33.8404,51.5371 33.81,51.3213Z"
android:strokeWidth="1.57676"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</group>
</vector>

View file

@ -23,7 +23,7 @@
android:scaleType="centerInside" android:scaleType="centerInside"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/profile_splash"/> tools:src="@tools:sample/avatars"/>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView <org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/basic_megaphone_title" android:id="@+id/basic_megaphone_title"
@ -63,14 +63,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
style="@style/Button.Borderless" style="@style/Button.Borderless"
app:layout_constraintStart_toEndOf="@id/basic_megaphone_snooze" app:layout_constraintStart_toEndOf="@id/basic_megaphone_secondary"
app:layout_constraintTop_toBottomOf="@id/basic_megaphone_content_barrier" app:layout_constraintTop_toBottomOf="@id/basic_megaphone_content_barrier"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
tools:text="*sigh*" tools:text="*sigh*"
tools:visibility="visible"/> tools:visibility="visible"/>
<Button <Button
android:id="@+id/basic_megaphone_snooze" android:id="@+id/basic_megaphone_secondary"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/full_screen_dialog_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_arrow_left_24"
app:titleTextAppearance="@style/TextSecure.TitleTextStyle"
tools:title="Dialog Title" />
<FrameLayout
android:id="@+id/full_screen_dialog_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="160dp"
android:background="@color/blue_100"
app:srcCompat="@drawable/signal_research" />
<TextView
android:id="@+id/research_megaphone_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="22dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="24dp"
android:text="@string/ResearchMegaphoneDialog_we_believe_in_privacy" />
<com.google.android.material.button.MaterialButton
android:id="@+id/research_megaphone_dialog_take_the_survey"
style="@style/Widget.Signal.Button.Flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="8dp"
android:text="@string/ResearchMegaphoneDialog_take_the_survey"
android:textColor="@color/core_white"
app:backgroundTint="?attr/colorAccent"
app:icon="@drawable/ic_open_20"
app:iconGravity="textEnd"
app:iconTint="@color/core_white" />
<com.google.android.material.button.MaterialButton
android:id="@+id/research_megaphone_dialog_no_thanks"
style="@style/Widget.Signal.Button.Flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="12dp"
android:text="@string/ResearchMegaphoneDialog_no_thanks"
android:textColor="?safety_number_change_dialog_button_text_color"
app:backgroundTint="?safety_number_change_dialog_button_background" />
<TextView
style="@style/TextAppearance.Signal.Caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:gravity="center"
android:text="@string/ResearchMegaphoneDialog_the_survey_is_hosted_by_surveygizmo_at_the_secure_domain" />
</LinearLayout>
</ScrollView>

View file

@ -2485,9 +2485,17 @@
<string name="KbsMegaphone__well_remind_you_later_creating_a_pin">We\'ll remind you later. Creating a PIN will become mandatory in %1$d days.</string> <string name="KbsMegaphone__well_remind_you_later_creating_a_pin">We\'ll remind you later. Creating a PIN will become mandatory in %1$d days.</string>
<string name="KbsMegaphone__well_remind_you_later_confirming_your_pin">We\'ll remind you later. Confirming your PIN will become mandatory in %1$d days.</string> <string name="KbsMegaphone__well_remind_you_later_confirming_your_pin">We\'ll remind you later. Confirming your PIN will become mandatory in %1$d days.</string>
<!-- Mention Megaphone --> <!-- Research Megaphone -->
<string name="MentionsMegaphone__introducing_mentions">Introducing @Mentions</string> <string name="ResearchMegaphone_tell_signal_what_you_think">Tell Signal what you think</string>
<string name="MentionsMegaphone__get_someones_attention_in_a_group_by_typing">Get someone\'s attention in a \"New Group\" by typing @</string> <string name="ResearchMegaphone_to_make_signal_the_best_messaging_app_on_the_planet">To make Signal the best messaging app on the planet, we\'d love to hear your feedback.</string>
<string name="ResearchMegaphone_learn_more">Learn more</string>
<string name="ResearchMegaphone_dismiss">Dismiss</string>
<string name="ResearchMegaphoneDialog_signal_research">Signal Research</string>
<string name="ResearchMegaphoneDialog_we_believe_in_privacy"><![CDATA[<p><b>We believe in privacy.</b></p><p>Signal doesn\'t track you or collect your data. To improve Signal for everyone, we rely on user feedback, <b>and we\'d love yours.</b></p><p>We\'re running a survey to understand how you use Signal. Our survey doesn\'t collect any data that will identify you. If youre interested in sharing additional feedback, you\'ll have the option to provide contact information.</p><p>If you have a few minutes and feedback to offer, we\'d love to hear from you.</p>]]></string>
<string name="ResearchMegaphoneDialog_take_the_survey">Take the survey</string>
<string name="ResearchMegaphoneDialog_no_thanks">No thanks</string>
<string name="ResearchMegaphoneDialog_the_survey_is_hosted_by_surveygizmo_at_the_secure_domain">The survey is hosted by Surveygizmo at the secure domain surveys.signalusers.org</string>
<!-- transport_selection_list_item --> <!-- transport_selection_list_item -->
<string name="transport_selection_list_item__transport_icon">Transport icon</string> <string name="transport_selection_list_item__transport_icon">Transport icon</string>

View file

@ -40,6 +40,19 @@
<item name="android:textColor">@null</item> <item name="android:textColor">@null</item>
</style> </style>
<style name="TextSecure.DarkTheme.FullScreenDialog">
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.FullScreenDialog</item>
</style>
<style name="TextSecure.LightTheme.FullScreenDialog">
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.FullScreenDialog</item>
</style>
<style name="TextSecure.Animation.FullScreenDialog" parent="@android:style/Animation.Activity">
<item name="android:windowEnterAnimation">@anim/fade_scale_in</item>
<item name="android:windowExitAnimation">@anim/fade_scale_out</item>
</style>
<!-- ActionBar styles --> <!-- ActionBar styles -->
<style name="TextSecure.DarkActionBar" <style name="TextSecure.DarkActionBar"
parent="@style/Widget.AppCompat.ActionBar"> parent="@style/Widget.AppCompat.ActionBar">

View file

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.util;
import org.junit.Test;
import org.signal.zkgroup.util.UUIDUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.UUID;
import static org.junit.Assert.*;
public class BucketingUtilTest {
@Test
public void bucket() {
// GIVEN
String key = "research.megaphone.1";
UUID uuid = UuidUtil.parseOrThrow("15b9729c-51ea-4ddb-b516-652befe78062");
long partPer = 1_000_000;
// WHEN
long countEnabled = BucketingUtil.bucket(key, uuid, partPer);
// THEN
assertEquals(243315, countEnabled);
}
}

View file

@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import com.google.protobuf.Empty;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.testutil.EmptyLogger;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.*;
@RunWith(Parameterized.class)
public class ResearchMegaphoneTest_determineCountEnabled {
private final String phoneNumber;
private final Map<String, Integer> countryCounts;
private final long output;
@Parameterized.Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
{"+1 555 555 5555", new HashMap<String, Integer>() {{
put("1", 10000);
put("*", 400);
}}, 10000},
{"+1 555 555 5555", new HashMap<String, Integer>() {{
put("011", 1000);
put("1", 20000);
}}, 20000},
{"+1 555 555 5555", new HashMap<String, Integer>() {{
put("011", 1000);
put("a", 123);
put("abba", 0);
}}, 0},
{"+1 555", new HashMap<String, Integer>() {{
put("011", 1000);
put("1", 1000);
}}, 1000},
{"+81 555 555 5555", new HashMap<String, Integer>() {{
put("81", 6000);
put("1", 1000);
put("*", 2000);
}}, 6000},
{"+81 555 555 5555", new HashMap<String, Integer>() {{
put("0011", 6000);
put("1", 1000);
put("*", 2000);
}}, 2000},
{"+49 555 555 5555", new HashMap<String, Integer>() {{
put("0011", 6000);
put("1", 1000);
put("*", 2000);
}}, 2000}
});
}
@BeforeClass
public static void setup() {
Log.initialize(new EmptyLogger());
}
public ResearchMegaphoneTest_determineCountEnabled(@NonNull String phoneNumber,
@NonNull Map<String, Integer> countryCounts,
long output)
{
this.phoneNumber = phoneNumber;
this.countryCounts = countryCounts;
this.output = output;
}
@Test
public void determineCountEnabled() {
assertEquals(output, ResearchMegaphone.determineCountEnabled(countryCounts, phoneNumber));
}
}

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.util;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
@RunWith(Parameterized.class)
public class ResearchMegaphoneTest_parseCountryCounts {
private final String input;
private final Map<String, Integer> output;
@Parameterized.Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
{"1:10000,*:400", new HashMap<String, Integer>() {{
put("1", 10000);
put("*", 400);
}}},
{"011:1000,1:1000", new HashMap<String, Integer>() {{
put("011", 1000);
put("1", 1000);
}}},
{"011:1000,1:1000,a:123,abba:abba", new HashMap<String, Integer>() {{
put("011", 1000);
put("1", 1000);
put("a", 123);
put("abba", 0);
}}},
{":,011:1000,1:1000,1:,:1,1:1:1", new HashMap<String, Integer>() {{
put("011", 1000);
put("1", 1000);
}}},
{"asdf", new HashMap<String, Integer>()},
{"asdf:", new HashMap<String, Integer>()},
{":,:,:", new HashMap<String, Integer>()},
{",,", new HashMap<String, Integer>()},
{"", new HashMap<String, Integer>()}
});
}
public ResearchMegaphoneTest_parseCountryCounts(String input, Map<String, Integer> output) {
this.input = input;
this.output = output;
}
@Test
public void parseCountryCounts() {
assertEquals(output, ResearchMegaphone.parseCountryCounts(input));
}
}