blob: d48e7396e1d18adad192a7e5541e5b183e9c7884 [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.example.odpsamplenetwork;
import android.adservices.ondevicepersonalization.AppInfo;
import android.adservices.ondevicepersonalization.DownloadInput;
import android.adservices.ondevicepersonalization.DownloadOutput;
import android.adservices.ondevicepersonalization.EventLogRecord;
import android.adservices.ondevicepersonalization.EventUrlProvider;
import android.adservices.ondevicepersonalization.ExecuteInput;
import android.adservices.ondevicepersonalization.ExecuteOutput;
import android.adservices.ondevicepersonalization.IsolatedWorker;
import android.adservices.ondevicepersonalization.KeyValueStore;
import android.adservices.ondevicepersonalization.RenderInput;
import android.adservices.ondevicepersonalization.RenderOutput;
import android.adservices.ondevicepersonalization.RenderingConfig;
import android.adservices.ondevicepersonalization.RequestLogRecord;
import android.adservices.ondevicepersonalization.UserData;
import android.adservices.ondevicepersonalization.WebViewEventInput;
import android.adservices.ondevicepersonalization.WebViewEventOutput;
import android.annotation.NonNull;
import android.content.ContentValues;
import android.os.PersistableBundle;
import android.os.Process;
import android.os.StrictMode;
import android.os.StrictMode.ThreadPolicy;
import android.util.Base64;
import android.util.JsonReader;
import android.util.Log;
import com.google.common.util.concurrent.FluentFuture;
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 com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.setfilters.cuckoofilter.CuckooFilter;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.function.Consumer;
public class SampleHandler implements IsolatedWorker {
public static final String TAG = "OdpSampleNetwork";
public static final int EVENT_TYPE_IMPRESSION = 1;
public static final int EVENT_TYPE_CLICK = 2;
public static final double COST_RAISING_FACTOR = 2.0;
private static final String AD_ID_KEY = "adid";
private static final String BID_PRICE_KEY = "price";
private static final String AUCTION_SCORE_KEY = "score";
private static final String CLICK_COST_KEY = "clkcost";
private static final String EVENT_TYPE_KEY = "type";
private static final int BID_PRICE_OFFSET = 0;
private static final String TRANSPARENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAA"
+ "AAXNSR0IArs4c6QAAAAtJREFUGFdjYAACAAAFAAGq1chRAAAAAElFTkSuQmCC";
private static final byte[] TRANSPARENT_PNG_BYTES = Base64.decode(TRANSPARENT_PNG_BASE64, 0);
private static final ListeningExecutorService sBackgroundExecutor =
MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(
/* nThreads */ 4,
createThreadFactory("BG Thread", Process.THREAD_PRIORITY_BACKGROUND,
Optional.of(getIoThreadPolicy()))));
private final KeyValueStore mRemoteData;
private final EventUrlProvider mEventUrlProvider;
private final UserData mUserData;
SampleHandler(KeyValueStore remoteData, EventUrlProvider eventUrlProvider,
UserData userData) {
mRemoteData = remoteData;
mEventUrlProvider = eventUrlProvider;
mUserData = userData;
if (mRemoteData == null) {
Log.e(TAG, "RemoteData missing");
}
if (mEventUrlProvider == null) {
Log.e(TAG, "EventUrlProvider missing");
}
if (mUserData == null) {
Log.e(TAG, "UserData missing");
}
}
@Override
public void onDownload(
@NonNull DownloadInput input,
@NonNull Consumer<DownloadOutput> consumer) {
Log.d(TAG, "onDownload() started.");
DownloadOutput downloadResult =
new DownloadOutput.Builder()
.setRetainedKeys(getFilteredKeys(input.getData()))
.build();
consumer.accept(downloadResult);
}
@Override public void onExecute(
@NonNull ExecuteInput input,
@NonNull Consumer<ExecuteOutput> consumer
) {
Log.d(TAG, "onExecute() started.");
sBackgroundExecutor.execute(() -> handleOnExecute(input, consumer));
}
@Override public void onRender(
@NonNull RenderInput input,
@NonNull Consumer<RenderOutput> consumer
) {
Log.d(TAG, "onRender() started.");
sBackgroundExecutor.execute(() -> handleOnRender(input, consumer));
}
@Override public void onWebViewEvent(
@NonNull WebViewEventInput input,
@NonNull Consumer<WebViewEventOutput> consumer) {
Log.d(TAG, "onWebViewEvent() started.");
sBackgroundExecutor.execute(
() -> handleOnWebViewEvent(input, consumer));
}
private ListenableFuture<List<Ad>> readAds(KeyValueStore remoteData) {
Log.d(TAG, "readAds() called.");
try {
ArrayList<Ad> ads = new ArrayList<>();
for (var key: remoteData.keySet()) {
Ad ad = parseAd(key, remoteData.get(key));
if (ad != null) {
ads.add(ad);
}
}
return Futures.immediateFuture(ads);
} catch (Exception e) {
return Futures.immediateFailedFuture(e);
}
}
private boolean isMatch(Ad ad, String requestKeyword) {
if (ad.mTargetKeywords != null && !ad.mTargetKeywords.isEmpty()) {
if (!ad.mTargetKeywords.contains(requestKeyword)) {
return false;
}
}
if (ad.mTargetApps != null && !ad.mTargetApps.isEmpty()) {
if (!isInstalledAppFound(ad.mTargetApps)) {
return false;
}
}
if (ad.mExcludes != null && !ad.mExcludes.isEmpty()) {
if (ad.mExcludes.contains(requestKeyword)) {
return false;
}
if (isInstalledAppFound(ad.mExcludes)) {
return false;
}
}
if (ad.mTargetKeywordFilter != null) {
if (!ad.mTargetKeywordFilter.contains(requestKeyword)) {
return false;
}
}
if (ad.mExcludeFilter != null) {
if (ad.mExcludeFilter.contains(requestKeyword)) {
return false;
}
if (isInstalledAppFound(ad.mExcludeFilter)) {
return false;
}
}
if (ad.mTargetAppFilter != null) {
if (!isInstalledAppFound(ad.mTargetAppFilter)) {
return false;
}
}
return true;
}
private List<Ad> matchAds(List<Ad> ads, ExecuteInput input) {
Log.d(TAG, "matchAds() called.");
String requestKeyword = "";
if (input != null && input.getAppParams() != null
&& input.getAppParams().getString("keyword") != null) {
requestKeyword = input.getAppParams().getString("keyword");
}
List<Ad> result = new ArrayList<>();
for (Ad ad: ads) {
if (isMatch(ad, requestKeyword)) {
result.add(ad);
}
}
return result;
}
private Ad runAuction(List<Ad> ads) {
Log.d(TAG, "runAuction() called.");
Ad winner = null;
double maxPrice = 0.0;
for (Ad ad: ads) {
if (ad.mPrice > maxPrice) {
winner = ad;
maxPrice = ad.mPrice;
}
}
return winner;
}
private ContentValues createLogRecord(String adId, double price, double score) {
ContentValues result = new ContentValues();
result.put(AD_ID_KEY, adId);
result.put(BID_PRICE_KEY, price);
result.put(AUCTION_SCORE_KEY, score);
return result;
}
private ExecuteOutput buildResult(Ad ad) {
Log.d(TAG, "buildResult() called.");
ContentValues logData = createLogRecord(ad.mId, ad.mPrice, ad.mPrice * 10.0);
return new ExecuteOutput.Builder()
.setRequestLogRecord(new RequestLogRecord.Builder().addRow(logData).build())
.addRenderingConfig(new RenderingConfig.Builder().addKey(ad.mId).build())
.build();
}
private void handleOnExecute(
@NonNull ExecuteInput input,
@NonNull Consumer<ExecuteOutput> consumer
) {
try {
var unused = FluentFuture.from(readAds(mRemoteData))
.transform(
ads -> buildResult(runAuction(matchAds(ads, input))),
sBackgroundExecutor)
.transform(
result -> {
consumer.accept(result);
return null;
},
MoreExecutors.directExecutor())
.catching(
Exception.class,
e -> {
Log.e(TAG, "Execution failed.", e);
consumer.accept(null);
return null;
},
MoreExecutors.directExecutor());
} catch (Exception e) {
Log.e(TAG, "handleOnExecute() failed", e);
consumer.accept(null);
}
}
private ListenableFuture<String> getImpressionTrackingUrl() {
try {
PersistableBundle eventParams = new PersistableBundle();
eventParams.putInt(EVENT_TYPE_KEY, EVENT_TYPE_IMPRESSION);
String url = mEventUrlProvider.getEventTrackingUrl(
eventParams, TRANSPARENT_PNG_BYTES, "image/png").toString();
return Futures.immediateFuture(url);
} catch (Exception e) {
return Futures.immediateFailedFuture(e);
}
}
private ListenableFuture<String> getClickTrackingUrl(
String landingPage) {
try {
PersistableBundle eventParams = new PersistableBundle();
eventParams.putInt(EVENT_TYPE_KEY, EVENT_TYPE_CLICK);
String url = mEventUrlProvider.getEventTrackingUrlWithRedirect(
eventParams, landingPage).toString();
return Futures.immediateFuture(url);
} catch (Exception e) {
return Futures.immediateFailedFuture(e);
}
}
private ListenableFuture<Ad> readAd(String id, KeyValueStore remoteData) {
try {
return Futures.immediateFuture(parseAd(id, remoteData.get(id)));
} catch (Exception e) {
return Futures.immediateFailedFuture(e);
}
}
private RenderOutput buildRenderOutput(
Ad ad, String impressionUrl, String clickUrl) {
if (ad.mTemplateId != null) {
PersistableBundle templateParams = new PersistableBundle();
templateParams.putString("impressionUrl", impressionUrl);
templateParams.putString("clickUrl", clickUrl);
templateParams.putString("adText", ad.mText);
return new RenderOutput.Builder()
.setTemplateId(ad.mTemplateId)
.setTemplateParams(templateParams)
.build();
} else {
String content =
"<img src=\"" + impressionUrl + "\">\n"
+ "<a href=\"" + clickUrl + "\">" + ad.mText + "</a>";
Log.d(TAG, "content: " + content);
return new RenderOutput.Builder().setContent(content).build();
}
}
private void handleOnRender(
@NonNull RenderInput input,
@NonNull Consumer<RenderOutput> consumer
) {
try {
Log.d(TAG, "handleOnRender() started.");
String id = input.getRenderingConfig().getKeys().get(0);
var adFuture = readAd(id, mRemoteData);
var impUrlFuture = getImpressionTrackingUrl();
var clickUrlFuture = FluentFuture.from(adFuture).transformAsync(
ad -> getClickTrackingUrl(ad.mLandingPage),
sBackgroundExecutor);
var unused = FluentFuture.from(
Futures.whenAllComplete(adFuture, impUrlFuture, clickUrlFuture)
.call(
() -> buildRenderOutput(
Futures.getDone(adFuture), Futures.getDone(impUrlFuture),
Futures.getDone(clickUrlFuture)),
MoreExecutors.directExecutor()))
.transform(
result -> {
consumer.accept(result);
return null;
},
MoreExecutors.directExecutor())
.catching(
Exception.class,
e -> {
Log.e(TAG, "Execution failed.", e);
consumer.accept(null);
return null;
},
MoreExecutors.directExecutor());
} catch (Exception e) {
Log.e(TAG, "handleOnRender failed.", e);
consumer.accept(null);
}
}
public void handleOnWebViewEvent(
@NonNull WebViewEventInput input,
@NonNull Consumer<WebViewEventOutput> consumer) {
try {
Log.d(TAG, "handleOnEvent() started.");
PersistableBundle eventParams = input.getParameters();
int eventType = eventParams.getInt(EVENT_TYPE_KEY);
if (eventType <= 0) {
consumer.accept(new WebViewEventOutput.Builder().build());
return;
}
ContentValues logData = null;
if (eventType == EVENT_TYPE_CLICK) {
double bidPrice = 0.0;
if (input.getRequestLogRecord() != null
&& input.getRequestLogRecord().getRows() != null
&& !input.getRequestLogRecord().getRows().isEmpty()) {
ContentValues row = input.getRequestLogRecord().getRows().get(0);
Double data = row.getAsDouble(BID_PRICE_KEY);
if (data != null) {
bidPrice = data.doubleValue();
}
}
double updatedPrice = bidPrice * COST_RAISING_FACTOR;
logData = new ContentValues();
logData.put(CLICK_COST_KEY, updatedPrice);
}
WebViewEventOutput result = new WebViewEventOutput.Builder()
.setEventLogRecord(
new EventLogRecord.Builder()
.setRowIndex(0)
.setType(eventType)
.setData(logData).build())
.build();
consumer.accept(result);
} catch (Exception e) {
Log.e(TAG, "handleOnEvent failed.", e);
consumer.accept(null);
}
}
boolean isInstalledAppFound(CuckooFilter<String> filter) {
if (mUserData == null) {
Log.i(TAG, "No userdata.");
return false;
}
if (mUserData.getAppInfo() == null || mUserData.getAppInfo().isEmpty()) {
Log.i(TAG, "No installed apps.");
return false;
}
if (filter == null) {
return false;
}
for (String app: mUserData.getAppInfo().keySet()) {
AppInfo value = mUserData.getAppInfo().get(app);
if (value != null && value.isInstalled() && filter.contains(app)) {
return true;
}
}
return false;
}
boolean isInstalledAppFound(List<String> apps) {
if (mUserData == null) {
Log.i(TAG, "No userdata.");
return false;
}
if (mUserData.getAppInfo() == null || mUserData.getAppInfo().isEmpty()) {
Log.i(TAG, "No installed apps.");
return false;
}
if (apps == null || apps.isEmpty()) {
return false;
}
for (String app: mUserData.getAppInfo().keySet()) {
if (apps.contains(app)) {
return true;
}
}
return false;
}
boolean isBlockedAd(Ad ad) {
return isInstalledAppFound(ad.mExcludeFilter) || isInstalledAppFound(ad.mExcludes);
}
private List<String> getFilteredKeys(Map<String, byte[]> data) {
Log.d(TAG, "getFilteredKeys() called.");
List<String> filteredKeys = new ArrayList<String>();
// Add all keys from the file into the list
for (String key : data.keySet()) {
if (key != null && data.get(key) != null) {
if (key.startsWith("ad")) {
Ad ad = parseAd(key, data.get(key));
if (ad != null && !isBlockedAd(ad)) {
filteredKeys.add(key);
}
} else if (key.startsWith("template")) {
filteredKeys.add(key);
}
}
}
return filteredKeys;
}
private static ThreadFactory createThreadFactory(
final String name, final int priority,
final Optional<StrictMode.ThreadPolicy> policy) {
return new ThreadFactoryBuilder()
.setDaemon(true)
.setNameFormat(name + " #%d")
.setThreadFactory(
new ThreadFactory() {
@Override
public Thread newThread(final Runnable runnable) {
return new Thread(new Runnable() {
@Override
public void run() {
if (policy.isPresent()) {
StrictMode.setThreadPolicy(policy.get());
}
// Process class operates on the current thread.
Process.setThreadPriority(priority);
runnable.run();
}
});
}
})
.build();
}
static class Ad {
final String mId;
final double mPrice;
final List<String> mTargetKeywords;
final List<String> mTargetApps;
final List<String> mExcludes;
final String mLandingPage;
final String mText;
final String mTemplateId;
final CuckooFilter<String> mTargetKeywordFilter;
final CuckooFilter<String> mTargetAppFilter;
final CuckooFilter<String> mExcludeFilter;
Ad(String id, double price, List<String> targetKeywords, List<String> targetApps,
List<String> excludes, String landingPage, String text, String templateId,
CuckooFilter<String> targetKeywordFilter,
CuckooFilter<String> targetAppFilter,
CuckooFilter<String> excludeFilter) {
mId = id;
mPrice = price;
mTargetKeywords = targetKeywords;
mTargetApps = targetApps;
mExcludes = excludes;
mLandingPage = landingPage;
mText = text;
mTemplateId = templateId;
mTargetKeywordFilter = targetKeywordFilter;
mTargetAppFilter = targetAppFilter;
mExcludeFilter = excludeFilter;
}
}
private static void readJsonArray(JsonReader reader, List<String> values) throws IOException {
reader.beginArray();
while (reader.hasNext()) {
String value = reader.nextString();
if (value != null && !value.isEmpty()) {
values.add(value);
}
}
reader.endArray();
}
Ad parseAd(String id, byte[] data) {
if (id == null || data == null) {
return null;
}
String dataStr = new String(data, StandardCharsets.UTF_8);
Log.d(TAG, "parseAd: " + id + " " + dataStr);
// TODO(b/263493591): Parse JSON ad.
try (JsonReader reader = new JsonReader(new StringReader(dataStr))) {
reader.beginObject();
double price = 0.0;
ArrayList<String> targetKeywords = new ArrayList<>();
ArrayList<String> targetApps = new ArrayList<>();
ArrayList<String> excludes = new ArrayList<>();
String landingPage = "";
String text = "Click Here!";
String templateId = null;
CuckooFilter<String> targetKeywordFilter = null;
CuckooFilter<String> targetAppFilter = null;
CuckooFilter<String> excludeFilter = null;
while (reader.hasNext()) {
String name = reader.nextName();
if (name.equals("price")) {
price = reader.nextDouble();
} else if (name.equals("keywords")) {
readJsonArray(reader, targetKeywords);
} else if (name.equals("apps")) {
readJsonArray(reader, targetApps);
} else if (name.equals("excludes")) {
readJsonArray(reader, excludes);
} else if (name.equals("landingPage")) {
landingPage = reader.nextString();
} else if (name.equals("text")) {
text = reader.nextString();
} else if (name.equals("template")) {
templateId = reader.nextString();
} else if (name.equals("keywordFilter")) {
targetKeywordFilter = CuckooFilterUtil.createCuckooFilter(reader.nextString());
} else if (name.equals("appFilter")) {
targetAppFilter = CuckooFilterUtil.createCuckooFilter(reader.nextString());
} else if (name.equals("excludeFilter")) {
excludeFilter = CuckooFilterUtil.createCuckooFilter(reader.nextString());
} else {
reader.skipValue();
}
}
reader.endObject();
return new Ad(id, price, targetKeywords, targetApps, excludes, landingPage, text,
templateId, targetKeywordFilter, targetAppFilter, excludeFilter);
} catch (Exception e) {
Log.e(TAG, "parseAd() failed.", e);
return null;
}
}
private static ThreadPolicy getIoThreadPolicy() {
return new ThreadPolicy.Builder()
.detectNetwork()
.detectResourceMismatches()
.detectUnbufferedIo()
.penaltyLog()
.build();
}
}