Add badges to remote config

This commit is contained in:
Ravi Khadiwala authored and ravi-signal committed 2026-07-28 16:20:05 -05:00
1 parent 257dd76b68
commit 8e9253181c
4 files changed
+189 -3

No files matched your search

@@ -110,6 +110,7 @@ import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.CaptchaClient;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;
@@ -1105,7 +1106,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ProductConfigurationGrpcService(config.getSubscription(), config.getOneTimeDonations(),
List.of(stripeManager, braintreeManager), profileBadgeConverter,
config.getBackupConfiguration().maxTotalMediaSize()),
new RemoteConfigurationGrpcService(remoteConfigsManager))
new RemoteConfigurationGrpcService(remoteConfigsManager, profileBadgeConverter,
config.getBadges().getBadges().stream()
.map(BadgeConfiguration::getId)
.toList()))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@@ -12,25 +12,49 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.signal.chat.common.Badge;
import org.signal.chat.remoteconfiguration.Badges;
import org.signal.chat.remoteconfiguration.Configuration;
import org.signal.chat.remoteconfiguration.GetBadgesRequest;
import org.signal.chat.remoteconfiguration.GetBadgesResponse;
import org.signal.chat.remoteconfiguration.GetConfigurationRequest;
import org.signal.chat.remoteconfiguration.GetConfigurationResponse;
import org.signal.chat.remoteconfiguration.SimpleRemoteConfigurationGrpc;
import org.signal.chat.remoteconfiguration.TaggedBadges;
import org.signal.chat.remoteconfiguration.TaggedConfiguration;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
public class RemoteConfigurationGrpcService extends SimpleRemoteConfigurationGrpc.RemoteConfigurationImplBase {
private static final GetConfigurationResponse CONFIGURATION_ETAG_MATCHED =
GetConfigurationResponse.newBuilder().setEtagMatched(true).build();
private static final GetBadgesResponse BADGES_ETAG_MATCHED =
GetBadgesResponse.newBuilder().setEtagMatched(true).build();
private final RemoteConfigsManager remoteConfigsManager;
private final BadgeTranslator badgeTranslator;
private final List<String> badgeIds;
public RemoteConfigurationGrpcService(final RemoteConfigsManager remoteConfigsManager) {
// Badge information varies based on the provided Accept-Language header. Here we cache the etag for the resolved
// language, so if the caller provides a matching etag we don't have to build the full badge response.
private final ConcurrentHashMap<Locale, ByteString> localeToBadgesEtag = new ConcurrentHashMap<>();
public RemoteConfigurationGrpcService(
final RemoteConfigsManager remoteConfigsManager,
final BadgeTranslator badgeTranslator,
final List<String> badgeIds) {
this.remoteConfigsManager = remoteConfigsManager;
this.badgeTranslator = badgeTranslator;
this.badgeIds = badgeIds;
}
@Override
@@ -54,6 +78,32 @@ public class RemoteConfigurationGrpcService extends SimpleRemoteConfigurationGrp
.build();
}
@Override
public GetBadgesResponse getBadges(final GetBadgesRequest request) {
final List<Locale> acceptableLanguages = RequestAttributesUtil.getAvailableAcceptedLocales();
final Locale locale = badgeTranslator.resolveLocale(acceptableLanguages);
final ByteString cachedEtag = localeToBadgesEtag.get(locale);
if (cachedEtag != null && cachedEtag.equals(request.getEtag())) {
return BADGES_ETAG_MATCHED;
}
final Map<String, Badge> badgesById = badgeIds.stream().collect(Collectors.toMap(
Function.identity(),
badgeId -> BadgeGrpcHelper.toGrpcBadge(badgeTranslator.translate(acceptableLanguages, badgeId))));
final Badges badges = Badges.newBuilder().putAllBadges(badgesById).build();
final TaggedBadges taggedBadges = TaggedBadges.newBuilder()
.setBadges(badges)
.setEtag(etag(badges))
.build();
// This could race and multiple threads could decide to build-and-cache. That's fine, they should all calculate
// the same etag.
localeToBadgesEtag.put(locale, taggedBadges.getEtag());
return GetBadgesResponse.newBuilder().setTaggedBadges(taggedBadges).build();
}
private static ByteString etag(final Message message) {
final ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getSerializedSize());
final CodedOutputStream cos = CodedOutputStream.newInstance(baos);
@@ -9,6 +9,7 @@ option java_multiple_files = true;
package org.signal.chat.remoteconfiguration;
import "org/signal/chat/common.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
@@ -20,6 +21,16 @@ service RemoteConfiguration {
// values are based on the authenticated account and caller's client platform,
// which is derived from the "User-Agent" header.
rpc GetConfiguration(GetConfigurationRequest) returns (GetConfigurationResponse) {}
// Returns detailed information for all configured badges, keyed by badge ID.
//
// Badge descriptions may contain localized strings. Callers should set their
// language preferences via an "Accept-Language" header on the request
// https://datatracker.ietf.org/doc/html/rfc3282#section-3
//
// Callers may cache a badges result for up to 1 day before checking (via
// etag) if there are any new updates.
rpc GetBadges(GetBadgesRequest) returns (GetBadgesResponse) {}
}
message GetConfigurationRequest {
@@ -54,3 +65,32 @@ message TaggedConfiguration {
// changed.
bytes etag = 2;
}
message GetBadgesRequest {
// If present, the etag from a prior GetBadgesResponse. If the provided etag
// matches the current badge etag, the server may elide the badge response.
bytes etag = 1;
}
message GetBadgesResponse {
oneof response {
// The full set of badges and corresponding etag.
TaggedBadges tagged_badges = 1;
// The etag in the request matched the current badge etag.
bool etag_matched = 2 [(tag.reason) = "etag_match"];
}
}
message Badges {
// A map of badge ID to the detailed information for that badge.
map<string, common.Badge> badges = 1;
}
message TaggedBadges {
Badges badges = 1;
// An entity tag for `badges`. This may be supplied in a subsequent request
// to optimize bandwidth when the set of badges has not changed.
bytes etag = 2;
}
@@ -5,8 +5,13 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.net.InetAddresses;
@@ -14,7 +19,9 @@ import com.google.protobuf.ByteString;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -23,23 +30,39 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.signal.chat.remoteconfiguration.GetBadgesRequest;
import org.signal.chat.remoteconfiguration.GetBadgesResponse;
import org.signal.chat.remoteconfiguration.GetConfigurationRequest;
import org.signal.chat.remoteconfiguration.GetConfigurationResponse;
import org.signal.chat.remoteconfiguration.RemoteConfigurationGrpc;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
class RemoteConfigurationGrpcServiceTest extends
SimpleBaseGrpcTest<RemoteConfigurationGrpcService, RemoteConfigurationGrpc.RemoteConfigurationBlockingStub> {
private static final List<String> BADGE_IDS = List.of("B1", "B2");
@Mock
private RemoteConfigsManager remoteConfigsManager;
@Mock
private BadgeTranslator badgeTranslator;
@Override
protected RemoteConfigurationGrpcService createServiceBeforeEachTest() {
getMockRequestAttributesInterceptor().setRequestAttributes(
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, null));
return new RemoteConfigurationGrpcService(remoteConfigsManager);
when(badgeTranslator.resolveLocale(any())).thenReturn(Locale.US);
when(badgeTranslator.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1",
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"))));
when(badgeTranslator.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2",
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"))));
return new RemoteConfigurationGrpcService(remoteConfigsManager, badgeTranslator, BADGE_IDS);
}
@Test
@@ -149,4 +172,73 @@ class RemoteConfigurationGrpcServiceTest extends
config1.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, _) -> a, () -> new TreeMap<String, String>(Comparator.reverseOrder()))))
);
}
@Test
void getBadges() {
final GetBadgesResponse response = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().build());
assertTrue(response.hasTaggedBadges());
final Map<String, org.signal.chat.common.Badge> badges =
response.getTaggedBadges().getBadges().getBadgesMap();
assertEquals(Set.of("B1", "B2"), badges.keySet());
assertEquals("B1", badges.get("B1").getId());
assertEquals("cat1", badges.get("B1").getCategory());
assertEquals("B2", badges.get("B2").getId());
}
@Test
void getBadgesEtagMatched() {
final GetBadgesResponse response = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().build());
final ByteString etag = response.getTaggedBadges().getEtag();
final GetBadgesResponse cachedResponse = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().setEtag(etag).build());
assertTrue(cachedResponse.getEtagMatched());
assertFalse(cachedResponse.hasTaggedBadges());
}
@Test
void getBadgesWrongEtag() {
final GetBadgesResponse response = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().setEtag(ByteString.copyFrom(new byte[32])).build());
assertFalse(response.hasEtagMatched());
assertTrue(response.hasTaggedBadges());
}
@Test
void getBadgesComputesOncePerLocale() {
final RequestAttributes frAttributes =
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "fr-fr");
final List<Locale> frLocales = List.of(Locale.FRANCE);
final RequestAttributes caAttributes =
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-ca");
final List<Locale> caLocales = List.of(Locale.CANADA);
when(badgeTranslator.resolveLocale(frLocales)).thenReturn(Locale.FRANCE);
when(badgeTranslator.resolveLocale(caLocales)).thenReturn(Locale.CANADA);
// return slightly different values based on the language so the locales configurations have different etags
when(badgeTranslator.translate(eq(frLocales), eq("B1")))
.thenReturn(new Badge("B1", "cat1", "name1", "desc1", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"))));
when(badgeTranslator.translate(eq(caLocales), eq("B1")))
.thenReturn(new Badge("B1", "dog1", "name1", "desc1", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"))));
getMockRequestAttributesInterceptor().setRequestAttributes(caAttributes);
final GetBadgesResponse caResponse = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().build());
final GetBadgesResponse caResponseCached = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().setEtag(caResponse.getTaggedBadges().getEtag()).build());
assertTrue(caResponseCached.getEtagMatched());
verify(badgeTranslator, times(1)).translate(eq(caLocales), eq("B1"));
getMockRequestAttributesInterceptor().setRequestAttributes(frAttributes);
final GetBadgesResponse frResponse = authenticatedServiceStub()
.getBadges(GetBadgesRequest.newBuilder().setEtag(caResponse.getTaggedBadges().getEtag()).build());
assertFalse(frResponse.hasEtagMatched());
verify(badgeTranslator, times(1)).translate(eq(frLocales), eq("B1"));
}
}