|
|
|
|
@@ -0,0 +1,396 @@
|
|
|
|
|
/*
|
|
|
|
|
* Copyright 2024 Signal Messenger, LLC
|
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
package org.whispersystems.textsecuregcm.subscriptions;
|
|
|
|
|
|
|
|
|
|
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
|
|
|
|
|
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
|
|
|
|
|
import com.google.api.client.http.HttpResponseException;
|
|
|
|
|
import com.google.api.client.json.gson.GsonFactory;
|
|
|
|
|
import com.google.api.services.androidpublisher.AndroidPublisher;
|
|
|
|
|
import com.google.api.services.androidpublisher.AndroidPublisherRequest;
|
|
|
|
|
import com.google.api.services.androidpublisher.AndroidPublisherScopes;
|
|
|
|
|
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
|
|
|
|
|
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
|
|
|
|
|
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
|
|
|
|
|
import com.google.auth.http.HttpCredentialsAdapter;
|
|
|
|
|
import com.google.auth.oauth2.GoogleCredentials;
|
|
|
|
|
import com.google.common.annotations.VisibleForTesting;
|
|
|
|
|
import io.micrometer.core.instrument.Metrics;
|
|
|
|
|
import io.micrometer.core.instrument.Tags;
|
|
|
|
|
import java.io.IOException;
|
|
|
|
|
import java.io.InputStream;
|
|
|
|
|
import java.security.GeneralSecurityException;
|
|
|
|
|
import java.time.Clock;
|
|
|
|
|
import java.time.Instant;
|
|
|
|
|
import java.time.format.DateTimeParseException;
|
|
|
|
|
import java.util.Arrays;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.Objects;
|
|
|
|
|
import java.util.Optional;
|
|
|
|
|
import java.util.concurrent.CompletableFuture;
|
|
|
|
|
import java.util.concurrent.Executor;
|
|
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
import javax.ws.rs.core.Response;
|
|
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
|
|
|
|
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
|
|
|
|
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
|
|
|
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
|
|
|
|
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manages subscriptions made with the Play Billing API
|
|
|
|
|
* <p>
|
|
|
|
|
* Clients create a subscription using Play Billing directly, and then notify us about their subscription with their
|
|
|
|
|
* <a href="https://developer.android.com/google/play/billing/#concepts">purchaseToken</a>. This class provides methods
|
|
|
|
|
* for
|
|
|
|
|
* <ul>
|
|
|
|
|
* <li> <a href="https://developer.android.com/google/play/billing/security#verify">validating purchaseTokens</a> </li>
|
|
|
|
|
* <li> <a href="https://developer.android.com/google/play/billing/integrate#subscriptions">acknowledging purchaseTokens</a> </li>
|
|
|
|
|
* <li> querying the current status of a token's underlying subscription </li>
|
|
|
|
|
* </ul>
|
|
|
|
|
*/
|
|
|
|
|
public class GooglePlayBillingManager implements SubscriptionManager.Processor {
|
|
|
|
|
|
|
|
|
|
private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);
|
|
|
|
|
|
|
|
|
|
private final AndroidPublisher androidPublisher;
|
|
|
|
|
private final Executor executor;
|
|
|
|
|
private final String packageName;
|
|
|
|
|
private final Map<String, Long> productIdToLevel;
|
|
|
|
|
private final Clock clock;
|
|
|
|
|
|
|
|
|
|
private static final String VALIDATE_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "validate");
|
|
|
|
|
private static final String CANCEL_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "cancel");
|
|
|
|
|
private static final String GET_RECEIPT_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "getReceipt");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public GooglePlayBillingManager(
|
|
|
|
|
final InputStream credentialsStream,
|
|
|
|
|
final String packageName,
|
|
|
|
|
final String applicationName,
|
|
|
|
|
final Map<String, Long> productIdToLevel,
|
|
|
|
|
final Executor executor)
|
|
|
|
|
throws GeneralSecurityException, IOException {
|
|
|
|
|
this(new AndroidPublisher.Builder(
|
|
|
|
|
GoogleNetHttpTransport.newTrustedTransport(),
|
|
|
|
|
GsonFactory.getDefaultInstance(),
|
|
|
|
|
new HttpCredentialsAdapter(GoogleCredentials
|
|
|
|
|
.fromStream(credentialsStream)
|
|
|
|
|
.createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER)))
|
|
|
|
|
.setApplicationName(applicationName)
|
|
|
|
|
.build(),
|
|
|
|
|
Clock.systemUTC(), packageName, productIdToLevel, executor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@VisibleForTesting
|
|
|
|
|
GooglePlayBillingManager(
|
|
|
|
|
final AndroidPublisher androidPublisher,
|
|
|
|
|
final Clock clock,
|
|
|
|
|
final String packageName,
|
|
|
|
|
final Map<String, Long> productIdToLevel,
|
|
|
|
|
final Executor executor) {
|
|
|
|
|
this.clock = clock;
|
|
|
|
|
this.androidPublisher = androidPublisher;
|
|
|
|
|
this.productIdToLevel = productIdToLevel;
|
|
|
|
|
this.executor = Objects.requireNonNull(executor);
|
|
|
|
|
this.packageName = packageName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public PaymentProvider getProvider() {
|
|
|
|
|
return PaymentProvider.GOOGLE_PLAY_BILLING;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Represents a valid purchaseToken that should be durably stored and then acknowledged with
|
|
|
|
|
* {@link #acknowledgePurchase()}
|
|
|
|
|
*/
|
|
|
|
|
public class ValidatedToken {
|
|
|
|
|
|
|
|
|
|
private final long level;
|
|
|
|
|
private final String productId;
|
|
|
|
|
private final String purchaseToken;
|
|
|
|
|
// If false, the purchase has already been acknowledged
|
|
|
|
|
private final boolean requiresAck;
|
|
|
|
|
|
|
|
|
|
ValidatedToken(final long level, final String productId, final String purchaseToken, final boolean requiresAck) {
|
|
|
|
|
this.level = level;
|
|
|
|
|
this.productId = productId;
|
|
|
|
|
this.purchaseToken = purchaseToken;
|
|
|
|
|
this.requiresAck = requiresAck;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Acknowledge the purchase to the play billing server. If a purchase is never acknowledged, it will eventually be
|
|
|
|
|
* refunded.
|
|
|
|
|
*
|
|
|
|
|
* @return A stage that completes when the purchase has been successfully acknowledged
|
|
|
|
|
*/
|
|
|
|
|
public CompletableFuture<Void> acknowledgePurchase() {
|
|
|
|
|
if (!requiresAck) {
|
|
|
|
|
// We've already acknowledged this purchase on a previous attempt, nothing to do
|
|
|
|
|
return CompletableFuture.completedFuture(null);
|
|
|
|
|
}
|
|
|
|
|
return executeAsync(pub -> pub.purchases().subscriptions()
|
|
|
|
|
.acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public long getLevel() {
|
|
|
|
|
return level;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if the purchaseToken is valid. If it's valid it should be durably associated with the user's subscriberId and
|
|
|
|
|
* then acknowledged with {@link ValidatedToken#acknowledgePurchase()}
|
|
|
|
|
*
|
|
|
|
|
* @param purchaseToken The play store billing purchaseToken that represents a subscription purchase
|
|
|
|
|
* @return A stage that completes successfully when the token has been validated, or fails if the token does not
|
|
|
|
|
* represent an active purchase
|
|
|
|
|
*/
|
|
|
|
|
public CompletableFuture<ValidatedToken> validateToken(String purchaseToken) {
|
|
|
|
|
return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
|
|
|
|
|
|
|
|
|
|
final SubscriptionState state = SubscriptionState
|
|
|
|
|
.fromString(subscription.getSubscriptionState())
|
|
|
|
|
.orElse(SubscriptionState.UNSPECIFIED);
|
|
|
|
|
|
|
|
|
|
Metrics.counter(VALIDATE_COUNTER_NAME, subscriptionTags(subscription)).increment();
|
|
|
|
|
|
|
|
|
|
// We only ever acknowledge valid tokens. There are cases where a subscription was once valid and then was
|
|
|
|
|
// cancelled, so the user could still be entitled to their purchase. However, if we never acknowledge it, the
|
|
|
|
|
// user's charge will eventually be refunded anyway. See
|
|
|
|
|
// https://developer.android.com/google/play/billing/integrate#pending
|
|
|
|
|
if (state != SubscriptionState.ACTIVE) {
|
|
|
|
|
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired(
|
|
|
|
|
"Cannot acknowledge purchase for subscription in state " + subscription.getSubscriptionState()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final AcknowledgementState acknowledgementState = AcknowledgementState
|
|
|
|
|
.fromString(subscription.getAcknowledgementState())
|
|
|
|
|
.orElse(AcknowledgementState.UNSPECIFIED);
|
|
|
|
|
|
|
|
|
|
final boolean requiresAck = switch (acknowledgementState) {
|
|
|
|
|
case ACKNOWLEDGED -> false;
|
|
|
|
|
case PENDING -> true;
|
|
|
|
|
case UNSPECIFIED -> throw ExceptionUtils.wrap(
|
|
|
|
|
new IOException("Invalid acknowledgement state " + subscription.getAcknowledgementState()));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
|
|
|
|
|
final long level = productIdToLevel(purchase.getProductId());
|
|
|
|
|
|
|
|
|
|
return new ValidatedToken(level, purchase.getProductId(), purchaseToken, requiresAck);
|
|
|
|
|
}, executor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cancel the subscription. Cancellation stops auto-renewal, but does not refund the user nor cut off access to their
|
|
|
|
|
* entitlement until their current period expires.
|
|
|
|
|
*
|
|
|
|
|
* @param purchaseToken The purchaseToken associated with the subscription
|
|
|
|
|
* @return A stage that completes when the subscription has successfully been cancelled
|
|
|
|
|
*/
|
|
|
|
|
public CompletableFuture<Void> cancelAllActiveSubscriptions(String purchaseToken) {
|
|
|
|
|
return lookupSubscription(purchaseToken).thenCompose(subscription -> {
|
|
|
|
|
Metrics.counter(CANCEL_COUNTER_NAME, subscriptionTags(subscription)).increment();
|
|
|
|
|
|
|
|
|
|
final SubscriptionState state = SubscriptionState
|
|
|
|
|
.fromString(subscription.getSubscriptionState())
|
|
|
|
|
.orElse(SubscriptionState.UNSPECIFIED);
|
|
|
|
|
|
|
|
|
|
if (state == SubscriptionState.CANCELED || state == SubscriptionState.EXPIRED) {
|
|
|
|
|
// already cancelled, nothing to do
|
|
|
|
|
return CompletableFuture.completedFuture(null);
|
|
|
|
|
}
|
|
|
|
|
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
|
|
|
|
|
|
|
|
|
|
return executeAsync(pub ->
|
|
|
|
|
pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public CompletableFuture<ReceiptItem> getReceiptItem(String purchaseToken) {
|
|
|
|
|
return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
|
|
|
|
|
final AcknowledgementState acknowledgementState = AcknowledgementState
|
|
|
|
|
.fromString(subscription.getAcknowledgementState())
|
|
|
|
|
.orElse(AcknowledgementState.UNSPECIFIED);
|
|
|
|
|
if (acknowledgementState != AcknowledgementState.ACKNOWLEDGED) {
|
|
|
|
|
// We should only ever generate receipts for a stored and acknowledged token.
|
|
|
|
|
logger.error("Tried to fetch receipt for purchaseToken {} that was never acknowledged", purchaseToken);
|
|
|
|
|
throw new IllegalStateException("Tried to fetch receipt for purchaseToken that was never acknowledged");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Metrics.counter(GET_RECEIPT_COUNTER_NAME, subscriptionTags(subscription)).increment();
|
|
|
|
|
|
|
|
|
|
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
|
|
|
|
|
final Instant expiration = getExpiration(purchase)
|
|
|
|
|
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("Invalid subscription expiration")));
|
|
|
|
|
|
|
|
|
|
if (expiration.isBefore(clock.instant())) {
|
|
|
|
|
// We don't need to check any state at this point, just whether the subscription is currently valid. If the
|
|
|
|
|
// subscription is in a grace period, the expiration time will be dynamically extended, see
|
|
|
|
|
// https://developer.android.com/google/play/billing/lifecycle/subscriptions#grace-period
|
|
|
|
|
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new ReceiptItem(
|
|
|
|
|
subscription.getLatestOrderId(),
|
|
|
|
|
PaymentTime.periodEnds(expiration),
|
|
|
|
|
productIdToLevel(purchase.getProductId()));
|
|
|
|
|
}, executor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface ApiCall<T> {
|
|
|
|
|
|
|
|
|
|
AndroidPublisherRequest<T> req(AndroidPublisher publisher) throws IOException;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Asynchronously execute a synchronous API call from an AndroidPublisher
|
|
|
|
|
*
|
|
|
|
|
* @param apiCall A function that takes the publisher and returns the API call to execute
|
|
|
|
|
* @param <R> The return type of the executed ApiCall
|
|
|
|
|
* @return A stage that completes with the result of the API call
|
|
|
|
|
*/
|
|
|
|
|
private <R> CompletableFuture<R> executeAsync(final ApiCall<R> apiCall) {
|
|
|
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
|
|
|
try {
|
|
|
|
|
return apiCall.req(androidPublisher).execute();
|
|
|
|
|
} catch (GoogleJsonResponseException e) {
|
|
|
|
|
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
|
|
|
|
|
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
|
|
|
|
|
}
|
|
|
|
|
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), e.getDetails(), e);
|
|
|
|
|
throw ExceptionUtils.wrap(e);
|
|
|
|
|
} catch (HttpResponseException e) {
|
|
|
|
|
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
|
|
|
|
|
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
|
|
|
|
|
}
|
|
|
|
|
logger.warn("Unexpected HTTP status code {} from androidpublisher", e.getStatusCode(), e);
|
|
|
|
|
throw ExceptionUtils.wrap(e);
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
throw ExceptionUtils.wrap(e);
|
|
|
|
|
}
|
|
|
|
|
}, executor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CompletableFuture<SubscriptionPurchaseV2> lookupSubscription(final String purchaseToken) {
|
|
|
|
|
return executeAsync(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private long productIdToLevel(final String productId) {
|
|
|
|
|
final Long level = this.productIdToLevel.get(productId);
|
|
|
|
|
if (level == null) {
|
|
|
|
|
logger.error("productId={} had no associated level", productId);
|
|
|
|
|
// This was a productId a user was able to successfully purchase from our catalog,
|
|
|
|
|
// but we don't know about it. The server's configuration is behind.
|
|
|
|
|
throw new IllegalStateException("no level found for productId " + productId);
|
|
|
|
|
}
|
|
|
|
|
return level;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private SubscriptionPurchaseLineItem getLineItem(final SubscriptionPurchaseV2 subscription) {
|
|
|
|
|
final List<SubscriptionPurchaseLineItem> lineItems = subscription.getLineItems();
|
|
|
|
|
if (lineItems.isEmpty()) {
|
|
|
|
|
throw new IllegalArgumentException("Subscriptions should have line items");
|
|
|
|
|
}
|
|
|
|
|
if (lineItems.size() > 1) {
|
|
|
|
|
logger.warn("{} line items found for purchase {}, expected 1", lineItems.size(), subscription.getLatestOrderId());
|
|
|
|
|
}
|
|
|
|
|
return lineItems.getFirst();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Tags subscriptionTags(final SubscriptionPurchaseV2 subscription) {
|
|
|
|
|
final boolean expired = subscription.getLineItems().isEmpty() ||
|
|
|
|
|
getExpiration(getLineItem(subscription)).orElse(Instant.EPOCH).isBefore(clock.instant());
|
|
|
|
|
return Tags.of(
|
|
|
|
|
"expired", Boolean.toString(expired),
|
|
|
|
|
"subscriptionState", subscription.getSubscriptionState(),
|
|
|
|
|
"acknowledgementState", subscription.getAcknowledgementState());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Optional<Instant> getExpiration(final SubscriptionPurchaseLineItem purchaseLineItem) {
|
|
|
|
|
if (StringUtils.isBlank(purchaseLineItem.getExpiryTime())) {
|
|
|
|
|
return Optional.empty();
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return Optional.of(Instant.parse(purchaseLineItem.getExpiryTime()));
|
|
|
|
|
} catch (DateTimeParseException e) {
|
|
|
|
|
logger.warn("received an expiry time with an invalid format: {}", purchaseLineItem.getExpiryTime());
|
|
|
|
|
return Optional.empty();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState
|
|
|
|
|
@VisibleForTesting
|
|
|
|
|
enum SubscriptionState {
|
|
|
|
|
UNSPECIFIED("SUBSCRIPTION_STATE_UNSPECIFIED"),
|
|
|
|
|
PENDING("SUBSCRIPTION_STATE_PENDING"),
|
|
|
|
|
ACTIVE("SUBSCRIPTION_STATE_ACTIVE"),
|
|
|
|
|
PAUSED("SUBSCRIPTION_STATE_PAUSED"),
|
|
|
|
|
IN_GRACE_PERIOD("SUBSCRIPTION_STATE_IN_GRACE_PERIOD"),
|
|
|
|
|
ON_HOLD("SUBSCRIPTION_STATE_ON_HOLD"),
|
|
|
|
|
CANCELED("SUBSCRIPTION_STATE_CANCELED"),
|
|
|
|
|
EXPIRED("SUBSCRIPTION_STATE_EXPIRED"),
|
|
|
|
|
PENDING_PURCHASE_CANCELED("SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED");
|
|
|
|
|
|
|
|
|
|
private static final Map<String, SubscriptionState> VALUES = Arrays
|
|
|
|
|
.stream(SubscriptionState.values())
|
|
|
|
|
.collect(Collectors.toMap(ss -> ss.s, ss -> ss));
|
|
|
|
|
|
|
|
|
|
private final String s;
|
|
|
|
|
|
|
|
|
|
SubscriptionState(String s) {
|
|
|
|
|
this.s = s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Optional<SubscriptionState> fromString(String s) {
|
|
|
|
|
return Optional.ofNullable(SubscriptionState.VALUES.getOrDefault(s, null));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@VisibleForTesting
|
|
|
|
|
String apiString() {
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#AcknowledgementState
|
|
|
|
|
@VisibleForTesting
|
|
|
|
|
enum AcknowledgementState {
|
|
|
|
|
UNSPECIFIED("ACKNOWLEDGEMENT_STATE_UNSPECIFIED"),
|
|
|
|
|
PENDING("ACKNOWLEDGEMENT_STATE_PENDING"),
|
|
|
|
|
ACKNOWLEDGED("ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED");
|
|
|
|
|
|
|
|
|
|
private static final Map<String, AcknowledgementState> VALUES = Arrays
|
|
|
|
|
.stream(AcknowledgementState.values())
|
|
|
|
|
.collect(Collectors.toMap(as -> as.s, ss -> ss));
|
|
|
|
|
|
|
|
|
|
private final String s;
|
|
|
|
|
|
|
|
|
|
AcknowledgementState(String s) {
|
|
|
|
|
this.s = s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Optional<AcknowledgementState> fromString(String s) {
|
|
|
|
|
return Optional.ofNullable(AcknowledgementState.VALUES.getOrDefault(s, null));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@VisibleForTesting
|
|
|
|
|
String apiString() {
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|