diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManager.java new file mode 100644 index 000000000..b3cf2bf6e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManager.java @@ -0,0 +1,74 @@ +package org.whispersystems.textsecuregcm.sms; + +import com.google.common.annotations.VisibleForTesting; +import java.util.List; +import java.util.Locale.LanguageRange; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; + +public class TwilioVerifyExperimentEnrollmentManager { + + @VisibleForTesting + static final String EXPERIMENT_NAME = "twilio_verify_v1"; + + private final ExperimentEnrollmentManager experimentEnrollmentManager; + + private static final Set INELIGIBLE_CLIENTS = Set.of("android-ng", "android-2020-01"); + + private final Set signalExclusiveVoiceVerificationLanguages; + + public TwilioVerifyExperimentEnrollmentManager(final VoiceVerificationConfiguration voiceVerificationConfiguration, + final ExperimentEnrollmentManager experimentEnrollmentManager) { + this.experimentEnrollmentManager = experimentEnrollmentManager; + + // Signal voice verification supports several languages that Verify does not. We want to honor + // clients that prioritize these languages, even if they would normally be enrolled in the experiment + signalExclusiveVoiceVerificationLanguages = voiceVerificationConfiguration.getLocales().stream() + .map(loc -> loc.split("-")[0]) + .filter(language -> !TwilioVerifySender.TWILIO_VERIFY_LANGUAGES.contains(language)) + .collect(Collectors.toSet()); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + public boolean isEnrolled(Optional clientType, String number, List languageRanges, + String transport) { + + final boolean clientEligible = clientType.map(client -> !INELIGIBLE_CLIENTS.contains(client)) + .orElse(true); + + final boolean languageEligible; + + if ("sms".equals(transport)) { + // Signal only sends SMS in en, while Verify supports en + many other languages + languageEligible = true; + } else { + + boolean clientPreferredLanguageOnlySupportedBySignal = false; + + for (LanguageRange languageRange : languageRanges) { + final String language = languageRange.getRange().split("-")[0]; + + if (signalExclusiveVoiceVerificationLanguages.contains(language)) { + // Support is exclusive to Signal. + // Since this is the first match in the priority list, so let's break and honor it + clientPreferredLanguageOnlySupportedBySignal = true; + break; + } + if (TwilioVerifySender.TWILIO_VERIFY_LANGUAGES.contains(language)) { + // Twilio supports it, so we can stop looping + break; + } + + // the language is supported by neither, so let's loop again + } + + languageEligible = !clientPreferredLanguageOnlySupportedBySignal; + } + final boolean enrolled = experimentEnrollmentManager.isEnrolled(number, EXPERIMENT_NAME); + + return clientEligible && languageEligible && enrolled; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManagerTest.java new file mode 100644 index 000000000..c57936912 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifyExperimentEnrollmentManagerTest.java @@ -0,0 +1,89 @@ +package org.whispersystems.textsecuregcm.sms; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Locale.LanguageRange; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; + +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +class TwilioVerifyExperimentEnrollmentManagerTest { + + private final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class); + private final VoiceVerificationConfiguration voiceVerificationConfiguration = mock(VoiceVerificationConfiguration.class); + private TwilioVerifyExperimentEnrollmentManager manager; + + private static final String NUMBER = "+15055551212"; + + private static final Optional INELIGIBLE_CLIENT = Optional.of("android-2020-01"); + private static final Optional ELIGIBLE_CLIENT = Optional.of("anything"); + + private static final List LANGUAGE_ONLY_SUPPORTED_BY_SIGNAL = LanguageRange.parse("am"); + private static final List LANGUAGE_NOT_SUPPORTED_BY_SIGNAL_OR_TWILIO = LanguageRange.parse("xx"); + private static final List LANGUAGE_SUPPORTED_BY_TWILIO = LanguageRange.parse("fr-CA"); + + + @BeforeEach + void setup() { + when(voiceVerificationConfiguration.getLocales()) + .thenReturn(Set.of("am", "en-US", "fr-CA")); + + manager = new TwilioVerifyExperimentEnrollmentManager( + voiceVerificationConfiguration, + experimentEnrollmentManager); + } + + @ParameterizedTest + @MethodSource + void testIsEnrolled(String message, boolean expected, Optional clientType, String number, + List languageRanges, String transport, boolean managerResponse) { + + when(experimentEnrollmentManager.isEnrolled(number, TwilioVerifyExperimentEnrollmentManager.EXPERIMENT_NAME)) + .thenReturn(managerResponse); + assertEquals(expected, manager.isEnrolled(clientType, number, languageRanges, transport), message); + } + + static Stream testIsEnrolled() { + return Stream.of( + Arguments.of("ineligible client", false, INELIGIBLE_CLIENT, NUMBER, Collections.emptyList(), "sms", true), + Arguments + .of("ineligible client", false, Optional.of("android-ng"), NUMBER, Collections.emptyList(), "sms", true), + Arguments + .of("client, language, and manager all agree on enrollment", true, ELIGIBLE_CLIENT, NUMBER, + LANGUAGE_SUPPORTED_BY_TWILIO, + "sms", true), + + Arguments + .of("enrolled: ineligible language doesn’t matter with sms", true, ELIGIBLE_CLIENT, NUMBER, + LANGUAGE_ONLY_SUPPORTED_BY_SIGNAL, "sms", + true), + + Arguments + .of("not enrolled: language only supported by Signal is preferred", false, ELIGIBLE_CLIENT, NUMBER, List.of( + LANGUAGE_ONLY_SUPPORTED_BY_SIGNAL.get(0), LANGUAGE_SUPPORTED_BY_TWILIO.get(0)), "voice", true), + + Arguments.of("enrolled: preferred language is supported", true, ELIGIBLE_CLIENT, NUMBER, List.of( + LANGUAGE_SUPPORTED_BY_TWILIO.get(0), LANGUAGE_ONLY_SUPPORTED_BY_SIGNAL + .get(0)), "voice", true), + + Arguments + .of("enrolled: preferred (and only) language is not supported by Signal or Twilio", true, ELIGIBLE_CLIENT, + NUMBER, LANGUAGE_NOT_SUPPORTED_BY_SIGNAL_OR_TWILIO, "voice", true), + + Arguments.of("not enrolled: preferred language (and only) is only supported by Siganl", false, ELIGIBLE_CLIENT, + NUMBER, LANGUAGE_ONLY_SUPPORTED_BY_SIGNAL, "voice", true) + + ); + } +}