Implement the ProfileBadgeConverter interface

This commit is contained in:
Ehren Kret 2021-09-15 10:32:20 -05:00 committed by GitHub
parent 5f8accb492
commit 79ad09524e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 331 additions and 0 deletions

View File

@ -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<String, BadgeConfiguration> 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<Badge> convert(
final List<Locale> acceptableLanguages,
final Set<AccountBadge> accountBadges) {
if (accountBadges.isEmpty()) {
return Set.of();
}
final Instant now = clock.instant();
final List<Locale> 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<String> 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());
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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<BadgeConfiguration> 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<ResourceBundle.Control> setupResourceBundle(Locale expectedLocale) {
ArgumentCaptor<ResourceBundle.Control> 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<Arguments> 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<Control> 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);
}
}
}