diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientRelease.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientRelease.java new file mode 100644 index 000000000..7d90958d5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientRelease.java @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import com.vdurmont.semver4j.Semver; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import java.time.Instant; + +public record ClientRelease(ClientPlatform platform, Semver version, Instant release, Instant expiration) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientReleases.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientReleases.java new file mode 100644 index 000000000..b1844af51 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientReleases.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import com.vdurmont.semver4j.Semver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import reactor.core.publisher.Flux; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import javax.annotation.Nullable; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; + +public class ClientReleases { + + private final DynamoDbAsyncClient dynamoDbAsyncClient; + private final String tableName; + + public static final String ATTR_PLATFORM = "P"; + public static final String ATTR_VERSION = "V"; + public static final String ATTR_RELEASE_TIMESTAMP = "T"; + public static final String ATTR_EXPIRATION = "E"; + + private static final Logger logger = LoggerFactory.getLogger(ClientReleases.class); + + public ClientReleases(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) { + this.dynamoDbAsyncClient = dynamoDbAsyncClient; + this.tableName = tableName; + } + + public Map> getClientReleases() { + return Collections.unmodifiableMap( + Flux.from(dynamoDbAsyncClient.scanPaginator(ScanRequest.builder() + .tableName(tableName) + .build()) + .items()) + .mapNotNull(ClientReleases::releaseFromItem) + .groupBy(ClientRelease::platform) + .flatMap(groupedFlux -> groupedFlux.collectMap(ClientRelease::version) + .map(releasesByVersion -> Tuples.of(groupedFlux.key(), releasesByVersion))) + .collectMap(Tuple2::getT1, Tuple2::getT2) + .blockOptional() + .orElseGet(Collections::emptyMap)); + } + + @Nullable + static ClientRelease releaseFromItem(final Map item) { + try { + final ClientPlatform platform = ClientPlatform.valueOf(item.get(ATTR_PLATFORM).s()); + final Semver version = new Semver(item.get(ATTR_VERSION).s()); + final Instant release = Instant.ofEpochSecond(Long.parseLong(item.get(ATTR_RELEASE_TIMESTAMP).n())); + final Instant expiration = Instant.ofEpochSecond(Long.parseLong(item.get(ATTR_EXPIRATION).n())); + + return new ClientRelease(platform, version, release, expiration); + } catch (final Exception e) { + logger.warn("Failed to parse client release item", e); + return null; + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientReleasesTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientReleasesTest.java new file mode 100644 index 000000000..2097bec30 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientReleasesTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.vdurmont.semver4j.Semver; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; + +class ClientReleasesTest { + + private ClientReleases clientReleases; + + @RegisterExtension + static final DynamoDbExtension DYNAMO_DB_EXTENSION = + new DynamoDbExtension(DynamoDbExtensionSchema.Tables.CLIENT_RELEASES); + + @BeforeEach + void setUp() { + clientReleases = new ClientReleases(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), + DynamoDbExtensionSchema.Tables.CLIENT_RELEASES.tableName()); + } + + @Test + void getClientReleases() { + final Instant releaseTimestamp = Instant.now().truncatedTo(ChronoUnit.SECONDS); + final Instant expiration = releaseTimestamp.plusSeconds(60); + + storeClientRelease("IOS", "1.2.3", releaseTimestamp, expiration); + storeClientRelease("IOS", "not-a-valid-version", releaseTimestamp, expiration); + storeClientRelease("ANDROID", "4.5.6", releaseTimestamp, expiration); + storeClientRelease("UNRECOGNIZED_PLATFORM", "7.8.9", releaseTimestamp, expiration); + + final Map> expectedVersions = Map.of( + ClientPlatform.IOS, Map.of(new Semver("1.2.3"), new ClientRelease(ClientPlatform.IOS, new Semver("1.2.3"), releaseTimestamp, expiration)), + ClientPlatform.ANDROID, Map.of(new Semver("4.5.6"), new ClientRelease(ClientPlatform.ANDROID, new Semver("4.5.6"), releaseTimestamp, expiration))); + + assertEquals(expectedVersions, clientReleases.getClientReleases()); + } + + private void storeClientRelease(final String platform, final String version, final Instant release, final Instant expiration) { + DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder() + .tableName(DynamoDbExtensionSchema.Tables.CLIENT_RELEASES.tableName()) + .item(Map.of( + ClientReleases.ATTR_PLATFORM, AttributeValue.builder().s(platform).build(), + ClientReleases.ATTR_VERSION, AttributeValue.builder().s(version).build(), + ClientReleases.ATTR_RELEASE_TIMESTAMP, + AttributeValue.builder().n(String.valueOf(release.getEpochSecond())).build(), + ClientReleases.ATTR_EXPIRATION, + AttributeValue.builder().n(String.valueOf(expiration.getEpochSecond())).build())) + .build()); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java index 7a30f66d8..bb35b5ecf 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java @@ -31,12 +31,12 @@ import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; public class DynamoDbExtension implements BeforeEachCallback, AfterEachCallback { public interface TableSchema { - public String tableName(); - public String hashKeyName(); - public String rangeKeyName(); - public List attributeDefinitions(); - public List globalSecondaryIndexes(); - public List localSecondaryIndexes(); + String tableName(); + String hashKeyName(); + String rangeKeyName(); + List attributeDefinitions(); + List globalSecondaryIndexes(); + List localSecondaryIndexes(); } record RawSchema( diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java index ee293c684..2ca680404 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java @@ -47,6 +47,21 @@ public final class DynamoDbExtensionSchema { ), List.of()), + CLIENT_RELEASES("client_releases_test", + ClientReleases.ATTR_PLATFORM, + ClientReleases.ATTR_VERSION, + List.of( + AttributeDefinition.builder() + .attributeName(ClientReleases.ATTR_PLATFORM) + .attributeType(ScalarAttributeType.S) + .build(), + AttributeDefinition.builder() + .attributeName(ClientReleases.ATTR_VERSION) + .attributeType(ScalarAttributeType.S) + .build()), + List.of(), + List.of()), + DELETED_ACCOUNTS("deleted_accounts_test", DeletedAccounts.KEY_ACCOUNT_E164, null,