diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java new file mode 100644 index 0000000000..a2e68a4c8e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java @@ -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(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java index 34d60392fc..861f540536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java @@ -12,8 +12,6 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery; 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.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -31,12 +29,8 @@ public class MentionsPickerViewModel extends ViewModel { private final MutableLiveData liveRecipient; private final MutableLiveData liveQuery; private final MutableLiveData isShowing; - private final MegaphoneRepository megaphoneRepository; - MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository, - @NonNull MegaphoneRepository megaphoneRepository) - { - this.megaphoneRepository = megaphoneRepository; + MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) { this.liveRecipient = new MutableLiveData<>(); this.liveQuery = new MutableLiveData<>(); this.selectedRecipient = new SingleLiveEvent<>(); @@ -56,7 +50,6 @@ public class MentionsPickerViewModel extends ViewModel { void onSelectionChange(@NonNull Recipient recipient) { selectedRecipient.setValue(recipient); - megaphoneRepository.markFinished(Megaphones.Event.MENTIONS); } void setIsShowing(boolean isShowing) { @@ -119,8 +112,7 @@ public class MentionsPickerViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions - return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()), - ApplicationDependencies.getMegaphoneRepository())); + return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 55eb5b1005..4d8ef1d444 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -55,6 +55,7 @@ import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.TooltipCompat; import androidx.core.content.res.ResourcesCompat; import androidx.core.view.ViewCompat; +import androidx.fragment.app.DialogFragment; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleOwner; @@ -423,6 +424,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode viewModel.onMegaphoneCompleted(event); } + @Override + public void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment) { + dialogFragment.show(getChildFragmentManager(), "megaphone_dialog"); + } + private void onReminderAction(@IdRes int reminderActionId) { if (reminderActionId == R.id.reminder_action_update_now) { PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java index ba13b70982..0de9510e63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java @@ -14,11 +14,11 @@ import org.thoughtcrime.securesms.R; public class BasicMegaphoneView extends FrameLayout { - private ImageView image; - private TextView titleText; - private TextView bodyText; - private Button actionButton; - private Button snoozeButton; + private ImageView image; + private TextView titleText; + private TextView bodyText; + private Button actionButton; + private Button secondaryButton; private Megaphone megaphone; private MegaphoneActionController megaphoneListener; @@ -36,11 +36,11 @@ public class BasicMegaphoneView extends FrameLayout { private void init(@NonNull Context context) { inflate(context, R.layout.basic_megaphone_view, this); - this.image = findViewById(R.id.basic_megaphone_image); - this.titleText = findViewById(R.id.basic_megaphone_title); - this.bodyText = findViewById(R.id.basic_megaphone_body); - this.actionButton = findViewById(R.id.basic_megaphone_action); - this.snoozeButton = findViewById(R.id.basic_megaphone_snooze); + this.image = findViewById(R.id.basic_megaphone_image); + this.titleText = findViewById(R.id.basic_megaphone_title); + this.bodyText = findViewById(R.id.basic_megaphone_body); + this.actionButton = findViewById(R.id.basic_megaphone_action); + this.secondaryButton = findViewById(R.id.basic_megaphone_secondary); } @Override @@ -89,17 +89,27 @@ public class BasicMegaphoneView extends FrameLayout { actionButton.setVisibility(GONE); } - if (megaphone.canSnooze()) { - snoozeButton.setVisibility(VISIBLE); - snoozeButton.setOnClickListener(v -> { - megaphoneListener.onMegaphoneSnooze(megaphone.getEvent()); + if (megaphone.canSnooze() || megaphone.hasSecondaryButton()) { + secondaryButton.setVisibility(VISIBLE); - if (megaphone.getSnoozeListener() != null) { - megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener); - } - }); + if (megaphone.canSnooze()) { + secondaryButton.setOnClickListener(v -> { + megaphoneListener.onMegaphoneSnooze(megaphone.getEvent()); + + if (megaphone.getSnoozeListener() != null) { + megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener); + } + }); + } else { + secondaryButton.setText(megaphone.getSecondaryButtonText()); + secondaryButton.setOnClickListener(v -> { + if (megaphone.getSecondaryButtonClickListener() != null) { + megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener); + } + }); + } } else { - snoozeButton.setVisibility(GONE); + secondaryButton.setVisibility(GONE); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java index a001cb3e36..da90fd9e96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -28,20 +28,24 @@ public class Megaphone { private final int buttonTextRes; private final EventListener buttonListener; private final EventListener snoozeListener; + private final int secondaryButtonTextRes; + private final EventListener secondaryButtonListener; private final EventListener onVisibleListener; private Megaphone(@NonNull Builder builder) { - this.event = builder.event; - this.style = builder.style; - this.priority = builder.priority; - this.canSnooze = builder.canSnooze; - this.titleRes = builder.titleRes; - this.bodyRes = builder.bodyRes; - this.imageRequest = builder.imageRequest; - this.buttonTextRes = builder.buttonTextRes; - this.buttonListener = builder.buttonListener; - this.snoozeListener = builder.snoozeListener; - this.onVisibleListener = builder.onVisibleListener; + this.event = builder.event; + this.style = builder.style; + this.priority = builder.priority; + this.canSnooze = builder.canSnooze; + this.titleRes = builder.titleRes; + this.bodyRes = builder.bodyRes; + this.imageRequest = builder.imageRequest; + this.buttonTextRes = builder.buttonTextRes; + this.buttonListener = builder.buttonListener; + this.snoozeListener = builder.snoozeListener; + this.secondaryButtonTextRes = builder.secondaryButtonTextRes; + this.secondaryButtonListener = builder.secondaryButtonListener; + this.onVisibleListener = builder.onVisibleListener; } public @NonNull Event getEvent() { @@ -88,6 +92,18 @@ public class Megaphone { return snoozeListener; } + public @StringRes int getSecondaryButtonText() { + return secondaryButtonTextRes; + } + + public boolean hasSecondaryButton() { + return secondaryButtonTextRes != 0; + } + + public @Nullable EventListener getSecondaryButtonClickListener() { + return secondaryButtonListener; + } + public @Nullable EventListener getOnVisibleListener() { return onVisibleListener; } @@ -105,6 +121,8 @@ public class Megaphone { private int buttonTextRes; private EventListener buttonListener; private EventListener snoozeListener; + private int secondaryButtonTextRes; + private EventListener secondaryButtonListener; private EventListener onVisibleListener; @@ -159,6 +177,12 @@ public class Megaphone { 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) { this.onVisibleListener = listener; return this; diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java index 6aea1a9cb0..66fdb31fc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneActionController.java @@ -5,6 +5,7 @@ import android.content.Intent; import androidx.annotation.NonNull; import androidx.annotation.StringRes; +import androidx.fragment.app.DialogFragment; public interface MegaphoneActionController { /** @@ -36,4 +37,9 @@ public interface MegaphoneActionController { * Called when a megaphone completed its goal. */ void onMegaphoneCompleted(@NonNull Megaphones.Event event); + + /** + * When a megaphone wnats to show a dialog fragment. + */ + void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java index a0d4c73c8a..4e76f25c02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.megaphone; import android.content.Context; import androidx.annotation.AnyThread; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -53,7 +52,7 @@ public class MegaphoneRepository { executor.execute(() -> { database.markFinished(Event.REACTIONS); database.markFinished(Event.MESSAGE_REQUESTS); - database.markFinished(Event.MENTIONS); + database.markFinished(Event.RESEARCH); resetDatabaseCache(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index e792b5a34c..e9c0fc832a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivit import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ResearchMegaphone; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.LinkedHashMap; @@ -85,9 +86,9 @@ public final class Megaphones { put(Event.PINS_FOR_ALL, new PinsForAllSchedule()); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER); - put(Event.MENTIONS, shouldShowMentionsMegaphone() ? ALWAYS : NEVER); put(Event.LINK_PREVIEWS, shouldShowLinkPreviewsMegaphone(context) ? 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); case MESSAGE_REQUESTS: return buildMessageRequestsMegaphone(context); - case MENTIONS: - return buildMentionsMegaphone(); case LINK_PREVIEWS: return buildLinkPreviewsMegaphone(); case CLIENT_DEPRECATED: return buildClientDeprecatedMegaphone(context); + case RESEARCH: + return buildResearchMegaphone(context); default: throw new IllegalArgumentException("Event not handled!"); } @@ -189,14 +190,6 @@ public final class Megaphones { .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() { return new Megaphone.Builder(Event.LINK_PREVIEWS, Megaphone.Style.LINK_PREVIEWS) .setPriority(Megaphone.Priority.HIGH) @@ -207,9 +200,22 @@ public final class Megaphones { return new Megaphone.Builder(Event.CLIENT_DEPRECATED, Megaphone.Style.FULLSCREEN) .disableSnooze() .setPriority(Megaphone.Priority.HIGH) - .setOnVisibleListener((megaphone, listener) -> { - listener.onMegaphoneNavigationRequested(new Intent(context, ClientDeprecatedActivity.class)); + .setOnVisibleListener((megaphone, listener) -> 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(); } @@ -217,9 +223,8 @@ public final class Megaphones { return Recipient.self().getProfileName() == ProfileName.EMPTY; } - private static boolean shouldShowMentionsMegaphone() { - return false; -// return FeatureFlags.mentions(); + private static boolean shouldShowResearchMegaphone() { + return ResearchMegaphone.isInResearchMegaphone(); } private static boolean shouldShowLinkPreviewsMegaphone(@NonNull Context context) { @@ -231,9 +236,9 @@ public final class Megaphones { PINS_FOR_ALL("pins_for_all"), PIN_REMINDER("pin_reminder"), MESSAGE_REQUESTS("message_requests"), - MENTIONS("mentions"), LINK_PREVIEWS("link_previews"), - CLIENT_DEPRECATED("client_deprecated"); + CLIENT_DEPRECATED("client_deprecated"), + RESEARCH("research"); private final String key; diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java new file mode 100644 index 0000000000..4b190deb8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java new file mode 100644 index 0000000000..baf5114c67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ShowForDurationSchedule.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java new file mode 100644 index 0000000000..661d502a89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BucketingUtil.java @@ -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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 7fd824a723..0103cf2240 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -7,6 +7,9 @@ import androidx.annotation.VisibleForTesting; import com.annimon.stream.Stream; 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.JSONObject; @@ -17,7 +20,10 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; 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.HashSet; import java.util.Iterator; @@ -65,6 +71,7 @@ public final class FeatureFlags { private static final String VERIFY_V2 = "android.verifyV2"; private static final String PHONE_NUMBER_PRIVACY_VERSION = "android.phoneNumberPrivacyVersion"; 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 @@ -83,7 +90,8 @@ public final class FeatureFlags { USERNAMES, MENTIONS, VERIFY_V2, - CLIENT_EXPIRATION + CLIENT_EXPIRATION, + RESEARCH_MEGAPHONE_1 ); /** @@ -283,6 +291,11 @@ public final class FeatureFlags { 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 to fetch and store the secondary certificate diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ResearchMegaphone.java b/app/src/main/java/org/thoughtcrime/securesms/util/ResearchMegaphone.java new file mode 100644 index 0000000000..ac071918cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ResearchMegaphone.java @@ -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 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 parseCountryCounts(@NonNull String buckets) { + Map 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 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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index cec5916361..86b0b444db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -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 * useful for when exceptions are thrown inside of asynchronous systems (like runnables in an diff --git a/app/src/main/res/drawable-hdpi/signal_research.webp b/app/src/main/res/drawable-hdpi/signal_research.webp new file mode 100644 index 0000000000..6a0648ac6d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-mdpi/signal_research.webp b/app/src/main/res/drawable-mdpi/signal_research.webp new file mode 100644 index 0000000000..f0b6a30dba Binary files /dev/null and b/app/src/main/res/drawable-mdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xhdpi/signal_research.webp b/app/src/main/res/drawable-xhdpi/signal_research.webp new file mode 100644 index 0000000000..797342d232 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/signal_research.webp b/app/src/main/res/drawable-xxhdpi/signal_research.webp new file mode 100644 index 0000000000..319a3d14cf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/signal_research.webp b/app/src/main/res/drawable-xxxhdpi/signal_research.webp new file mode 100644 index 0000000000..6dadb13ff9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/signal_research.webp differ diff --git a/app/src/main/res/drawable/ic_research_megaphone.xml b/app/src/main/res/drawable/ic_research_megaphone.xml new file mode 100644 index 0000000000..308238f571 --- /dev/null +++ b/app/src/main/res/drawable/ic_research_megaphone.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/basic_megaphone_view.xml b/app/src/main/res/layout/basic_megaphone_view.xml index d9d535c47c..2d5b86f60c 100644 --- a/app/src/main/res/layout/basic_megaphone_view.xml +++ b/app/src/main/res/layout/basic_megaphone_view.xml @@ -23,7 +23,7 @@ android:scaleType="centerInside" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:src="@drawable/profile_splash"/> + tools:src="@tools:sample/avatars"/>