Implement the ProfileBadgeConverter interface
This commit is contained in:
parent
5f8accb492
commit
79ad09524e
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.entities;
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public class Badge {
|
public class Badge {
|
||||||
private final URL imageUrl;
|
private final URL imageUrl;
|
||||||
|
@ -35,4 +36,22 @@ public class Badge {
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue