mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-26 11:51:10 +01:00
Add Research Megaphone.
This commit is contained in:
committed by
Greyson Parrelli
parent
9dbb77c10a
commit
ca442970a3
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user