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"/>
Signal doesn\'t track you or collect your data. To improve Signal for everyone, we rely on user feedback, and we\'d love yours.
We\'re running a survey to understand how you use Signal. Our survey doesn\'t collect any data that will identify you. If you’re interested in sharing additional feedback, you\'ll have the option to provide contact information.
If you have a few minutes and feedback to offer, we\'d love to hear from you.
]]>
+ Take the survey
+ No thanks
+ The survey is hosted by Surveygizmo at the secure domain surveys.signalusers.orgTransport icon
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index a7c7c8d538..8c3f66b091 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -40,6 +40,19 @@
@null
+
+
+
+
+
+