blob: 20ecc3c422974018a11da8f49fc7ca29831fcb91 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.adservices.service.adselection;
import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_API_CALLED__API_NAME__SELECT_ADS;
import android.adservices.adselection.AdSelectionCallback;
import android.adservices.adselection.AdSelectionConfig;
import android.adservices.adselection.AdSelectionInput;
import android.adservices.adselection.AdSelectionResponse;
import android.adservices.common.AdSelectionSignals;
import android.adservices.common.AdServicesStatusUtils;
import android.adservices.common.FledgeErrorResponse;
import android.adservices.exceptions.AdServicesException;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.content.Context;
import android.net.Uri;
import android.os.RemoteException;
import android.util.Pair;
import com.android.adservices.LogUtil;
import com.android.adservices.data.adselection.AdSelectionEntryDao;
import com.android.adservices.data.adselection.DBAdSelection;
import com.android.adservices.data.adselection.DBBuyerDecisionLogic;
import com.android.adservices.data.customaudience.CustomAudienceDao;
import com.android.adservices.data.customaudience.DBCustomAudience;
import com.android.adservices.service.Flags;
import com.android.adservices.service.common.AdServicesHttpsClient;
import com.android.adservices.service.common.AppImportanceFilter;
import com.android.adservices.service.common.AppImportanceFilter.WrongCallingApplicationStateException;
import com.android.adservices.service.consent.ConsentManager;
import com.android.adservices.service.devapi.DevContext;
import com.android.adservices.service.stats.AdServicesLogger;
import com.android.internal.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
* Orchestrator that runs the Ads Auction/Bidding and Scoring logic The class expects the caller to
* create a concrete object instance of the class. The instances are mutually exclusive and do not
* share any values across shared class instance.
*
* <p>Class takes in an executor on which it runs the AdSelection logic
*/
public final class AdSelectionRunner {
@VisibleForTesting static final String AD_SELECTION_ERROR_PATTERN = "%s: %s";
@VisibleForTesting
static final String ERROR_AD_SELECTION_FAILURE = "Encountered failure during Ad Selection";
@VisibleForTesting static final String ERROR_NO_WINNING_AD_FOUND = "No winning Ads found";
@VisibleForTesting
static final String ERROR_NO_VALID_BIDS_FOR_SCORING = "No valid bids for scoring";
@VisibleForTesting static final String ERROR_NO_CA_AVAILABLE = "No Custom Audience available";
@VisibleForTesting
static final String ERROR_NO_BUYERS_AVAILABLE =
"The list of the custom audience buyers should not be empty.";
@VisibleForTesting
static final String AD_SELECTION_TIMED_OUT = "Ad selection exceeded allowed time limit";
public static final long DAY_IN_SECONDS = 60 * 60 * 24;
@NonNull private final Context mContext;
@NonNull private final CustomAudienceDao mCustomAudienceDao;
@NonNull private final AdSelectionEntryDao mAdSelectionEntryDao;
@NonNull private final ExecutorService mExecutorService;
@NonNull private final AdsScoreGenerator mAdsScoreGenerator;
@NonNull private final AdBidGenerator mAdBidGenerator;
@NonNull private final AdSelectionIdGenerator mAdSelectionIdGenerator;
@NonNull private final Clock mClock;
@NonNull private final ConsentManager mConsentManager;
@NonNull private final AdServicesLogger mAdServicesLogger;
@NonNull private final Flags mFlags;
@NonNull private final AppImportanceFilter mAppImportanceFilter;
private final int mCallerUid;
public AdSelectionRunner(
@NonNull final Context context,
@NonNull final CustomAudienceDao customAudienceDao,
@NonNull final AdSelectionEntryDao adSelectionEntryDao,
@NonNull final ExecutorService executorService,
@NonNull final ConsentManager consentManager,
@NonNull final AdServicesLogger adServicesLogger,
@NonNull final DevContext devContext,
@NonNull AppImportanceFilter appImportanceFilter,
@NonNull final Flags flags,
int callerUid) {
Objects.requireNonNull(context);
Objects.requireNonNull(customAudienceDao);
Objects.requireNonNull(adSelectionEntryDao);
Objects.requireNonNull(executorService);
Objects.requireNonNull(adServicesLogger);
Objects.requireNonNull(flags);
mContext = context;
mCustomAudienceDao = customAudienceDao;
mAdSelectionEntryDao = adSelectionEntryDao;
mExecutorService = executorService;
mConsentManager = consentManager;
mAdServicesLogger = adServicesLogger;
mAdsScoreGenerator =
new AdsScoreGeneratorImpl(
new AdSelectionScriptEngine(mContext),
mExecutorService,
new AdServicesHttpsClient(mExecutorService),
devContext,
mAdSelectionEntryDao,
flags);
mAdBidGenerator =
new AdBidGeneratorImpl(
context, executorService, devContext, mCustomAudienceDao, flags);
mAdSelectionIdGenerator = new AdSelectionIdGenerator();
mClock = Clock.systemUTC();
mFlags = flags;
mAppImportanceFilter = appImportanceFilter;
mCallerUid = callerUid;
}
@VisibleForTesting
AdSelectionRunner(
@NonNull final Context context,
@NonNull final CustomAudienceDao customAudienceDao,
@NonNull final AdSelectionEntryDao adSelectionEntryDao,
@NonNull final ExecutorService executorService,
@NonNull final ConsentManager consentManager,
@NonNull final AdsScoreGenerator adsScoreGenerator,
@NonNull final AdBidGenerator adBidGenerator,
@NonNull final AdSelectionIdGenerator adSelectionIdGenerator,
@NonNull Clock clock,
@NonNull final AdServicesLogger adServicesLogger,
@NonNull AppImportanceFilter appImportanceFilter,
@NonNull final Flags flags,
int callerUid) {
Objects.requireNonNull(context);
Objects.requireNonNull(customAudienceDao);
Objects.requireNonNull(adSelectionEntryDao);
Objects.requireNonNull(executorService);
Objects.requireNonNull(adsScoreGenerator);
Objects.requireNonNull(adBidGenerator);
Objects.requireNonNull(adSelectionIdGenerator);
Objects.requireNonNull(clock);
Objects.requireNonNull(adServicesLogger);
Objects.requireNonNull(appImportanceFilter);
Objects.requireNonNull(flags);
mContext = context;
mCustomAudienceDao = customAudienceDao;
mAdSelectionEntryDao = adSelectionEntryDao;
mExecutorService = executorService;
mConsentManager = consentManager;
mAdsScoreGenerator = adsScoreGenerator;
mAdBidGenerator = adBidGenerator;
mAdSelectionIdGenerator = adSelectionIdGenerator;
mClock = clock;
mAdServicesLogger = adServicesLogger;
mFlags = flags;
mAppImportanceFilter = appImportanceFilter;
mCallerUid = callerUid;
}
/**
* Runs the ad selection for a given seller
*
* @param inputParams containing {@link AdSelectionConfig} and {@code callerPackageName}
* @param callback used to notify the result back to the calling seller
*/
public void runAdSelection(
@NonNull AdSelectionInput inputParams, @NonNull AdSelectionCallback callback) {
Objects.requireNonNull(inputParams);
Objects.requireNonNull(callback);
try {
ListenableFuture<Void> userConsentFuture =
Futures.submit(this::assertCallerHasUserConsent, mExecutorService);
ListenableFuture<DBAdSelection> dbAdSelectionFuture =
FluentFuture.from(userConsentFuture)
.transform(
ignoredVoid -> maybeAssertForegroundCaller(), mExecutorService)
.transformAsync(
ignoredVoid ->
orchestrateAdSelection(
inputParams.getAdSelectionConfig(),
inputParams.getCallerPackageName()),
mExecutorService);
Futures.addCallback(
dbAdSelectionFuture,
new FutureCallback<DBAdSelection>() {
@Override
public void onSuccess(DBAdSelection result) {
notifySuccessToCaller(result, callback);
// TODO(242280808): Schedule a clear for stale data instead of this hack
clearExpiredAdSelectionData();
}
@Override
public void onFailure(Throwable t) {
if (t instanceof ConsentManager.RevokedConsentException) {
notifyEmptySuccessToCaller(
callback,
AdServicesStatusUtils.STATUS_USER_CONSENT_REVOKED);
} else {
notifyFailureToCaller(callback, t);
}
// TODO(242280808): Schedule a clear for stale data instead of this hack
clearExpiredAdSelectionData();
}
},
mExecutorService);
} catch (Throwable t) {
notifyFailureToCaller(callback, t);
}
}
private void notifySuccessToCaller(
@NonNull DBAdSelection result, @NonNull AdSelectionCallback callback) {
int resultCode = AdServicesStatusUtils.STATUS_UNSET;
try {
callback.onSuccess(
new AdSelectionResponse.Builder()
.setAdSelectionId(result.getAdSelectionId())
.setRenderUri(result.getWinningAdRenderUri())
.build());
resultCode = AdServicesStatusUtils.STATUS_SUCCESS;
} catch (RemoteException e) {
LogUtil.e(e, "Encountered exception during notifying AdSelection callback");
resultCode = AdServicesStatusUtils.STATUS_UNKNOWN_ERROR;
} finally {
LogUtil.v(
"Ad Selection with Id:%d completed, attempted notifying success",
result.getAdSelectionId());
mAdServicesLogger.logFledgeApiCallStats(
AD_SERVICES_API_CALLED__API_NAME__SELECT_ADS, resultCode);
}
}
/** Sends a successful response to the caller that represents a silent failure. */
private void notifyEmptySuccessToCaller(@NonNull AdSelectionCallback callback, int resultCode) {
try {
callback.onSuccess(
new AdSelectionResponse.Builder()
.setAdSelectionId(mAdSelectionIdGenerator.generateId())
.setRenderUri(Uri.EMPTY)
.build());
} catch (RemoteException e) {
LogUtil.e(e, "Encountered exception during notifying AdSelection callback");
resultCode = AdServicesStatusUtils.STATUS_UNKNOWN_ERROR;
} finally {
mAdServicesLogger.logFledgeApiCallStats(
AD_SERVICES_API_CALLED__API_NAME__SELECT_ADS, resultCode);
}
}
private void notifyFailureToCaller(
@NonNull AdSelectionCallback callback, @NonNull Throwable t) {
int resultCode = AdServicesStatusUtils.STATUS_UNSET;
try {
if (t instanceof WrongCallingApplicationStateException) {
resultCode = AdServicesStatusUtils.STATUS_BACKGROUND_CALLER;
} else {
resultCode = AdServicesStatusUtils.STATUS_INTERNAL_ERROR;
}
FledgeErrorResponse selectionFailureResponse =
new FledgeErrorResponse.Builder()
.setErrorMessage(
String.format(
AD_SELECTION_ERROR_PATTERN,
ERROR_AD_SELECTION_FAILURE,
t.getMessage()))
.setStatusCode(resultCode)
.build();
LogUtil.e(t, "Ad Selection failure: ");
callback.onFailure(selectionFailureResponse);
} catch (RemoteException e) {
LogUtil.e(e, "Encountered exception during notifying AdSelection callback");
resultCode = AdServicesStatusUtils.STATUS_UNKNOWN_ERROR;
} finally {
mAdServicesLogger.logFledgeApiCallStats(
AD_SERVICES_API_CALLED__API_NAME__SELECT_ADS, resultCode);
}
}
/**
* Overall moderator for running Ad Selection
*
* @param adSelectionConfig Set of data from Sellers and Buyers needed for Ad Auction and
* Selection
* @return {@link AdSelectionResponse}
*/
private ListenableFuture<DBAdSelection> orchestrateAdSelection(
@NonNull final AdSelectionConfig adSelectionConfig,
@NonNull final String callerPackageName) {
LogUtil.v("Beginning Ad Selection Orchestration");
ListenableFuture<List<DBCustomAudience>> buyerCustomAudience =
getBuyersCustomAudience(adSelectionConfig);
AsyncFunction<List<DBCustomAudience>, List<AdBiddingOutcome>> bidAds =
buyerCAs -> {
return runAdBidding(buyerCAs, adSelectionConfig);
};
ListenableFuture<List<AdBiddingOutcome>> biddingOutcome =
Futures.transformAsync(buyerCustomAudience, bidAds, mExecutorService);
AsyncFunction<List<AdBiddingOutcome>, List<AdScoringOutcome>> mapBidsToScores =
bids -> {
return runAdScoring(bids, adSelectionConfig);
};
ListenableFuture<List<AdScoringOutcome>> scoredAds =
Futures.transformAsync(biddingOutcome, mapBidsToScores, mExecutorService);
Function<List<AdScoringOutcome>, AdScoringOutcome> reduceScoresToWinner =
scores -> {
return getWinningOutcome(scores);
};
ListenableFuture<AdScoringOutcome> winningOutcome =
Futures.transform(scoredAds, reduceScoresToWinner, mExecutorService);
Function<AdScoringOutcome, Pair<DBAdSelection.Builder, String>> mapWinnerToDBResult =
scoringWinner -> {
return createAdSelectionResult(scoringWinner);
};
ListenableFuture<Pair<DBAdSelection.Builder, String>> dbAdSelectionBuilder =
Futures.transform(winningOutcome, mapWinnerToDBResult, mExecutorService);
AsyncFunction<Pair<DBAdSelection.Builder, String>, DBAdSelection> saveResultToPersistence =
adSelectionAndJs -> {
return persistAdSelection(
adSelectionAndJs.first, adSelectionAndJs.second, callerPackageName);
};
return FluentFuture.from(dbAdSelectionBuilder)
.transformAsync(saveResultToPersistence, mExecutorService)
.withTimeout(
mFlags.getAdSelectionOverallTimeoutMs(),
TimeUnit.MILLISECONDS,
// TODO(b/237103033): Comply with thread usage policy for AdServices;
// use a global scheduled executor
new ScheduledThreadPoolExecutor(1))
.catching(
TimeoutException.class,
this::handleTimeoutError,
MoreExecutors.listeningDecorator(mExecutorService));
}
@Nullable
private DBAdSelection handleTimeoutError(TimeoutException e) {
LogUtil.e(e, "Ad Selection exceeded time limit");
throw new IllegalStateException(AD_SELECTION_TIMED_OUT);
}
private ListenableFuture<List<DBCustomAudience>> getBuyersCustomAudience(
final AdSelectionConfig adSelectionConfig) {
ListeningExecutorService listeningExecutorService =
MoreExecutors.listeningDecorator(mExecutorService);
return listeningExecutorService.submit(
() -> {
Preconditions.checkArgument(
!adSelectionConfig.getCustomAudienceBuyers().isEmpty(),
ERROR_NO_BUYERS_AVAILABLE);
List<DBCustomAudience> buyerCustomAudience =
mCustomAudienceDao.getActiveCustomAudienceByBuyers(
adSelectionConfig.getCustomAudienceBuyers(),
mClock.instant(),
mFlags.getFledgeCustomAudienceActiveTimeWindowInMs());
if (buyerCustomAudience == null || buyerCustomAudience.isEmpty()) {
// TODO(b/233296309) : Remove this exception after adding contextual ads
throw new IllegalStateException(ERROR_NO_CA_AVAILABLE);
}
return buyerCustomAudience;
});
}
private ListenableFuture<List<AdBiddingOutcome>> runAdBidding(
@NonNull final List<DBCustomAudience> customAudiences,
@NonNull final AdSelectionConfig adSelectionConfig)
throws InterruptedException, ExecutionException {
if (customAudiences.isEmpty()) {
LogUtil.w("Cannot invoke bidding on empty list of CAs");
return Futures.immediateFailedFuture(new Throwable("No CAs found for selection"));
}
// TODO(b/237004875) : Use common thread pool for parallel execution if possible
ForkJoinPool customThreadPool = new ForkJoinPool(getParallelBiddingCount());
final AtomicReference<List<ListenableFuture<AdBiddingOutcome>>> bidWinningAds =
new AtomicReference<>();
try {
LogUtil.d("Triggering bidding for all %d custom audiences", customAudiences.size());
customThreadPool
.submit(
() -> {
LogUtil.v("Invoking bidding for #%d CAs", customAudiences.size());
bidWinningAds.set(
customAudiences.parallelStream()
.map(
customAudience -> {
return runAdBiddingPerCA(
customAudience,
adSelectionConfig);
})
.collect(Collectors.toList()));
})
.get();
} catch (InterruptedException e) {
final String exceptionReason = "Bidding Interrupted Exception";
LogUtil.e(e, exceptionReason);
throw new InterruptedException(exceptionReason);
} catch (ExecutionException e) {
final String exceptionReason = "Bidding Execution Exception";
LogUtil.e(e, exceptionReason);
throw new ExecutionException(e.getCause());
} finally {
customThreadPool.shutdownNow();
}
return Futures.successfulAsList(bidWinningAds.get());
}
private int getParallelBiddingCount() {
int parallelBiddingCountConfigValue = mFlags.getAdSelectionConcurrentBiddingCount();
int numberOfAvailableProcessors = Runtime.getRuntime().availableProcessors();
return Math.min(parallelBiddingCountConfigValue, numberOfAvailableProcessors);
}
private ListenableFuture<AdBiddingOutcome> runAdBiddingPerCA(
@NonNull final DBCustomAudience customAudience,
@NonNull final AdSelectionConfig adSelectionConfig) {
LogUtil.v(String.format("Invoking bidding for CA: %s", customAudience.getName()));
// TODO(b/233239475) : Validate Buyer signals in Ad Selection Config
AdSelectionSignals buyerSignal =
Optional.ofNullable(
adSelectionConfig
.getPerBuyerSignals()
.get(customAudience.getBuyer()))
.orElse(AdSelectionSignals.EMPTY);
return mAdBidGenerator.runAdBiddingPerCA(
customAudience,
adSelectionConfig.getAdSelectionSignals(),
buyerSignal,
AdSelectionSignals.EMPTY,
adSelectionConfig);
// TODO(b/230569187): get the contextualSignal securely = "invoking app name"
}
@SuppressLint("DefaultLocale")
private ListenableFuture<List<AdScoringOutcome>> runAdScoring(
@NonNull final List<AdBiddingOutcome> adBiddingOutcomes,
@NonNull final AdSelectionConfig adSelectionConfig)
throws AdServicesException {
LogUtil.v("Got %d bidding outcomes", adBiddingOutcomes.size());
List<AdBiddingOutcome> validBiddingOutcomes =
adBiddingOutcomes.stream().filter(Objects::nonNull).collect(Collectors.toList());
if (validBiddingOutcomes.isEmpty()) {
LogUtil.w("Received empty list of Bidding outcomes");
throw new IllegalStateException(ERROR_NO_VALID_BIDS_FOR_SCORING);
}
return mAdsScoreGenerator.runAdScoring(validBiddingOutcomes, adSelectionConfig);
}
private AdScoringOutcome getWinningOutcome(
@NonNull List<AdScoringOutcome> overallAdScoringOutcome) {
LogUtil.v("Scoring completed, generating winning outcome");
return overallAdScoringOutcome.stream()
.filter(a -> a.getAdWithScore().getScore() > 0)
.max(
(a, b) ->
Double.compare(
a.getAdWithScore().getScore(),
b.getAdWithScore().getScore()))
.orElseThrow(() -> new IllegalStateException(ERROR_NO_WINNING_AD_FOUND));
}
/**
* This method populates an Ad Selection result ready to be persisted in DB, with all the fields
* except adSelectionId and creation time, which should be created as close as possible to
* persistence logic
*
* @param scoringWinner Winning Ad for overall Ad Selection
* @return A {@link Pair} with a Builder for {@link DBAdSelection} populated with necessary data
* and a string containing the JS with the decision logic from this buyer.
*/
@VisibleForTesting
Pair<DBAdSelection.Builder, String> createAdSelectionResult(
@NonNull AdScoringOutcome scoringWinner) {
DBAdSelection.Builder dbAdSelectionBuilder = new DBAdSelection.Builder();
LogUtil.v("Creating Ad Selection result from scoring winner");
dbAdSelectionBuilder
.setWinningAdBid(scoringWinner.getAdWithScore().getAdWithBid().getBid())
.setCustomAudienceSignals(
scoringWinner.getCustomAudienceBiddingInfo().getCustomAudienceSignals())
.setWinningAdRenderUri(
scoringWinner.getAdWithScore().getAdWithBid().getAdData().getRenderUri())
.setBiddingLogicUri(
scoringWinner.getCustomAudienceBiddingInfo().getBiddingLogicUrl())
.setContextualSignals("{}");
// TODO(b/230569187): get the contextualSignal securely = "invoking app name"
return Pair.create(
dbAdSelectionBuilder,
scoringWinner.getCustomAudienceBiddingInfo().getBuyerDecisionLogicJs());
}
private ListenableFuture<DBAdSelection> persistAdSelection(
@NonNull DBAdSelection.Builder dbAdSelectionBuilder,
@NonNull String buyerDecisionLogicJS,
@NonNull String callerPackageName) {
ListeningExecutorService listeningExecutorService =
MoreExecutors.listeningDecorator(mExecutorService);
final long adSelectionId = mAdSelectionIdGenerator.generateId();
LogUtil.v("Persisting Ad Selection Result for Id:%d", adSelectionId);
return listeningExecutorService.submit(
() -> {
// TODO : b/230568647 retry ID generation in case of collision
DBAdSelection dbAdSelection;
dbAdSelectionBuilder
.setAdSelectionId(adSelectionId)
.setCreationTimestamp(mClock.instant())
.setCallerPackageName(callerPackageName);
dbAdSelection = dbAdSelectionBuilder.build();
mAdSelectionEntryDao.persistAdSelection(dbAdSelection);
mAdSelectionEntryDao.persistBuyerDecisionLogic(
new DBBuyerDecisionLogic.Builder()
.setBuyerDecisionLogicJs(buyerDecisionLogicJS)
.setBiddingLogicUri(dbAdSelection.getBiddingLogicUri())
.build());
return dbAdSelection;
});
}
/**
* Asserts that FLEDGE APIs and the Privacy Sandbox as a whole have user consent.
*
* @return an ignorable {@code null}
* @throws ConsentManager.RevokedConsentException if FLEDGE or the Privacy Sandbox do not have
* user consent
*/
private Void assertCallerHasUserConsent() throws ConsentManager.RevokedConsentException {
if (!mConsentManager.getConsent(mContext.getPackageManager()).isGiven()) {
throw new ConsentManager.RevokedConsentException();
}
return null;
}
/**
* Asserts that the caller has the appropriate foreground status, if enabled.
*
* @return an ignorable {@code null}
* @throws WrongCallingApplicationStateException if the foreground check is enabled and fails
*/
private Void maybeAssertForegroundCaller() throws WrongCallingApplicationStateException {
if (mFlags.getEnforceForegroundStatusForFledgeRunAdSelection()) {
mAppImportanceFilter.assertCallerIsInForeground(
mCallerUid, AD_SERVICES_API_CALLED__API_NAME__SELECT_ADS, null);
}
return null;
}
private void clearExpiredAdSelectionData() {
Instant expirationTime = mClock.instant().minusSeconds(DAY_IN_SECONDS);
mAdSelectionEntryDao.removeExpiredAdSelection(expirationTime);
}
}