From 79ad09524e864f761a80e9bec65d052deeb0a22b Mon Sep 17 00:00:00 2001 From: Ehren Kret Date: Wed, 15 Sep 2021 10:32:20 -0500 Subject: [PATCH] Implement the ProfileBadgeConverter interface --- .../ConfiguredProfileBadgeConverter.java | 99 +++++++++ .../badges/ResourceBundleFactory.java | 13 ++ .../textsecuregcm/entities/Badge.java | 19 ++ .../ConfiguredProfileBadgeConverterTest.java | 200 ++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleFactory.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java new file mode 100644 index 000000000..ac1842dbd --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.badges; + +import com.google.common.annotations.VisibleForTesting; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.ResourceBundle; +import java.util.ResourceBundle.Control; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; +import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; +import org.whispersystems.textsecuregcm.entities.Badge; +import org.whispersystems.textsecuregcm.storage.AccountBadge; + +public class ConfiguredProfileBadgeConverter implements ProfileBadgeConverter { + + private static final int MAX_LOCALES = 15; + @VisibleForTesting + static final String BASE_NAME = "org.signal.badges.Badges"; + + private final Clock clock; + private final Map knownBadges; + private final ResourceBundleFactory resourceBundleFactory; + + public ConfiguredProfileBadgeConverter( + final Clock clock, + final BadgesConfiguration badgesConfiguration) { + this(clock, badgesConfiguration, ResourceBundle::getBundle); + } + + @VisibleForTesting + public ConfiguredProfileBadgeConverter( + final Clock clock, + final BadgesConfiguration badgesConfiguration, + final ResourceBundleFactory resourceBundleFactory) { + this.clock = clock; + this.knownBadges = badgesConfiguration.getBadges().stream() + .collect(Collectors.toMap(BadgeConfiguration::getName, Function.identity())); + this.resourceBundleFactory = resourceBundleFactory; + } + + @Override + public Set convert( + final List acceptableLanguages, + final Set accountBadges) { + if (accountBadges.isEmpty()) { + return Set.of(); + } + + final Instant now = clock.instant(); + + final List acceptableLocales = acceptableLanguages.stream().limit(MAX_LOCALES).distinct() + .collect(Collectors.toList()); + final Locale desiredLocale = acceptableLocales.isEmpty() ? Locale.getDefault() : acceptableLocales.get(0); + + // define a control with a fallback order as specified in the header + ResourceBundle.Control control = new Control() { + @Override + public List getFormats(final String baseName) { + Objects.requireNonNull(baseName); + return Control.FORMAT_PROPERTIES; + } + + @Override + public Locale getFallbackLocale(final String baseName, final Locale locale) { + Objects.requireNonNull(baseName); + if (locale.equals(Locale.getDefault())) { + return null; + } + final int localeIndex = acceptableLocales.indexOf(locale); + if (localeIndex < 0 || localeIndex >= acceptableLocales.size() - 1) { + return Locale.getDefault(); + } + // [0, acceptableLocales.size() - 2] is now the possible range for localeIndex + return acceptableLocales.get(localeIndex + 1); + } + }; + + final ResourceBundle resourceBundle = resourceBundleFactory.createBundle(BASE_NAME, desiredLocale, control); + return accountBadges.stream() + .filter(accountBadge -> accountBadge.isVisible() + && now.isBefore(accountBadge.getExpiration()) + && knownBadges.containsKey(accountBadge.getName())) + .map(accountBadge -> new Badge(knownBadges.get(accountBadge.getName()).getImageUrl(), + resourceBundle.getString(accountBadge.getName() + "_name"), + resourceBundle.getString(accountBadge.getName() + "_description"))) + .collect(Collectors.toSet()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleFactory.java new file mode 100644 index 000000000..626dbb523 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleFactory.java @@ -0,0 +1,13 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.badges; + +import java.util.Locale; +import java.util.ResourceBundle; + +public interface ResourceBundleFactory { + ResourceBundle createBundle(String baseName, Locale locale, ResourceBundle.Control control); +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java index 8bf659bd2..5d321c98d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.entities; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import java.net.URL; +import java.util.Objects; public class Badge { private final URL imageUrl; @@ -35,4 +36,22 @@ public class Badge { public String getDescription() { return description; } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Badge badge = (Badge) o; + return Objects.equals(imageUrl, badge.imageUrl) && Objects.equals(name, + badge.name) && Objects.equals(description, badge.description); + } + + @Override + public int hashCode() { + return Objects.hash(imageUrl, name, description); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java new file mode 100644 index 000000000..4efd59e0f --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.badges; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.ListResourceBundle; +import java.util.Locale; +import java.util.ResourceBundle; +import java.util.ResourceBundle.Control; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; +import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; +import org.whispersystems.textsecuregcm.entities.Badge; +import org.whispersystems.textsecuregcm.storage.AccountBadge; + +public class ConfiguredProfileBadgeConverterTest { + + private Clock clock; + private ResourceBundleFactory resourceBundleFactory; + private ResourceBundle resourceBundle; + + @BeforeEach + private void beforeEach() { + clock = mock(Clock.class); + resourceBundleFactory = mock(ResourceBundleFactory.class, (invocation) -> { + throw new UnsupportedOperationException(); + }); + + when(clock.instant()).thenReturn(Instant.ofEpochSecond(42)); + } + + private static String nameFor(int i) { + return "Badge-" + i; + } + + private static URL imageUrlFor(int i) { + try { + return new URL("https://example.com/badge/" + i); + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + } + + private static String rbNameFor(int i) { + return "TRANSLATED NAME " + i; + } + + private static String rbDesriptionFor(int i) { + return "TRANSLATED DESCRIPTION " + i; + } + + private static BadgeConfiguration newBadge(int i) { + return new BadgeConfiguration(nameFor(i), imageUrlFor(i)); + } + + private BadgesConfiguration createBadges(int count) { + List badges = new ArrayList<>(count); + Object[][] objects = new Object[count * 2][2]; + for (int i = 0; i < count; i++) { + badges.add(newBadge(i)); + objects[(i * 2)] = new Object[]{nameFor(i) + "_name", rbNameFor(i)}; + objects[(i * 2) + 1] = new Object[]{nameFor(i) + "_description", rbDesriptionFor(i)}; + } + resourceBundle = new ListResourceBundle() { + @Override + protected Object[][] getContents() { + return objects; + } + }; + return new BadgesConfiguration(badges); + } + + private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) { + return badgesConfiguration.getBadges().stream() + .filter(badgeConfiguration -> nameFor(i).equals(badgeConfiguration.getName())) + .findFirst().orElse(null); + } + + private ArgumentCaptor setupResourceBundle(Locale expectedLocale) { + ArgumentCaptor controlArgumentCaptor = + ArgumentCaptor.forClass(ResourceBundle.Control.class); + doReturn(resourceBundle).when(resourceBundleFactory).createBundle( + eq(ConfiguredProfileBadgeConverter.BASE_NAME), eq(expectedLocale), controlArgumentCaptor.capture()); + return controlArgumentCaptor; + } + + @Test + void testConvertEmptyList() { + BadgesConfiguration badgesConfiguration = createBadges(1); + ConfiguredProfileBadgeConverter badgeConverter = new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, + resourceBundleFactory); + assertThat(badgeConverter.convert(List.of(Locale.getDefault()), Set.of())).isNotNull().isEmpty(); + } + + @ParameterizedTest + @MethodSource + void testNoLocales(String name, Instant expiration, boolean visible, Badge expectedBadge) { + BadgesConfiguration badgesConfiguration = createBadges(1); + ConfiguredProfileBadgeConverter badgeConverter = + new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, resourceBundleFactory); + setupResourceBundle(Locale.getDefault()); + + if (expectedBadge != null) { + assertThat(badgeConverter.convert(List.of(), Set.of(new AccountBadge(name, expiration, visible)))).isNotNull() + .hasSize(1) + .containsOnly(expectedBadge); + } else { + assertThat(badgeConverter.convert(List.of(), Set.of(new AccountBadge(name, expiration, visible)))).isNotNull() + .isEmpty(); + } + } + + @SuppressWarnings("unused") + static Stream testNoLocales() { + Instant expired = Instant.ofEpochSecond(41); + Instant notExpired = Instant.ofEpochSecond(43); + return Stream.of( + arguments(nameFor(0), expired, false, null), + arguments(nameFor(0), notExpired, false, null), + arguments(nameFor(0), expired, true, null), + arguments(nameFor(0), notExpired, true, new Badge(imageUrlFor(0), rbNameFor(0), rbDesriptionFor(0))), + arguments(nameFor(1), expired, false, null), + arguments(nameFor(1), notExpired, false, null), + arguments(nameFor(1), expired, true, null), + arguments(nameFor(1), notExpired, true, null) + ); + } + + @Test + void testCustomControl() { + BadgesConfiguration badgesConfiguration = createBadges(1); + ConfiguredProfileBadgeConverter badgeConverter = + new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, resourceBundleFactory); + + Locale defaultLocale = Locale.getDefault(); + Locale enGb = new Locale("en", "GB"); + Locale en = new Locale("en"); + Locale esUs = new Locale("es", "US"); + + ArgumentCaptor controlArgumentCaptor = setupResourceBundle(enGb); + badgeConverter.convert(List.of(enGb, en, esUs), + Set.of(new AccountBadge(nameFor(0), Instant.ofEpochSecond(43), true))); + Control control = controlArgumentCaptor.getValue(); + + assertThatNullPointerException().isThrownBy(() -> control.getFormats(null)); + assertThatNullPointerException().isThrownBy(() -> control.getFallbackLocale(null, enGb)); + assertThatNullPointerException().isThrownBy( + () -> control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, null)); + + assertThat(control.getFormats(ConfiguredProfileBadgeConverter.BASE_NAME)).isNotNull().hasSize(1).containsOnly( + Control.FORMAT_PROPERTIES.toArray(new String[0])); + + try { + // temporarily override for purpose of ensuring this test doesn't change based on system default locale + Locale.setDefault(new Locale("xx", "XX")); + + assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo(en); + assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, en)).isEqualTo(esUs); + assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, esUs)).isEqualTo( + Locale.getDefault()); + assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull(); + + // now test what happens if the system default locale is in the list + // this should always terminate at the system default locale since the development defined bundle should get + // returned at that point anyhow + badgeConverter.convert(List.of(enGb, Locale.getDefault(), en, esUs), + Set.of(new AccountBadge(nameFor(0), Instant.ofEpochSecond(43), true))); + Control control2 = controlArgumentCaptor.getValue(); + + assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo( + Locale.getDefault()); + assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull(); + } finally { + Locale.setDefault(defaultLocale); + } + } +}