1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.adservices.service.adselection;
18 
19 import android.annotation.NonNull;
20 import android.net.Uri;
21 
22 import com.android.adservices.LoggerFactory;
23 import com.android.adservices.service.Flags;
24 import com.android.internal.annotations.VisibleForTesting;
25 
26 import java.util.HashSet;
27 import java.util.Objects;
28 import java.util.Set;
29 import java.util.regex.Matcher;
30 import java.util.regex.Pattern;
31 import java.util.stream.Collectors;
32 
33 /**
34  * Generates JS scripts given prebuilt URIs.
35  *
36  * <p>Prebuilt URIs are in '{@code ad-selection-prebuilt://<use-case>/<name>?<query-param>}'
37  *
38  * <p>
39  */
40 public class PrebuiltLogicGenerator {
41     private static final LoggerFactory.Logger sLogger = LoggerFactory.getFledgeLogger();
42     // TODO (b/271055928): Investigate abstracting use cases to their own classes with different
43     //  flavors
44     @VisibleForTesting
45     static final String UNKNOWN_PREBUILT_IDENTIFIER = "Unknown prebuilt identifier: '%s'!";
46 
47     @VisibleForTesting
48     static final String MISSING_PREBUILT_PARAMS = "Missing prebuilt URI query params: '%s'!";
49 
50     @VisibleForTesting
51     static final String PREBUILT_FEATURE_IS_DISABLED = "Prebuilt Uri feature is disabled!";
52 
53     @VisibleForTesting
54     static final String UNRECOGNIZED_PREBUILT_PARAMS =
55             "Unrecognized prebuilt URI query params: '%s'!";
56 
57     @VisibleForTesting
58     public static final String AD_SELECTION_PREBUILT_SCHEMA = "ad-selection-prebuilt";
59 
60     @VisibleForTesting public static final String AD_SELECTION_USE_CASE = "ad-selection";
61 
62     @VisibleForTesting
63     public static final String AD_SELECTION_HIGHEST_BID_WINS = "highest-bid-wins";
64 
65     @VisibleForTesting
66     static final String AD_SELECTION_HIGHEST_BID_WINS_JS =
67             "//From prebuilts AD_SELECTION_HIGHEST_BID_WINS_JS\n"
68                     + "function scoreAd(ad, bid, auction_config, seller_signals,"
69                     + " trusted_scoring_signals,\n"
70                     + "    contextual_signal, user_signal, custom_audience_signal) {\n"
71                     + "    return {'status': 0, 'score': bid };\n"
72                     + "}\n"
73                     + "function reportResult(ad_selection_config, render_uri, bid,"
74                     + " contextual_signals) {\n"
75                     + "    // Add the address of your reporting server here\n"
76                     + "    let reporting_address = '${reportingUrl}';\n"
77                     + "    return {'status': 0, 'results': {'signals_for_buyer':"
78                     + " '{\"signals_for_buyer\" : 1}'\n"
79                     + "            , 'reporting_uri': reporting_address + '?render_uri='\n"
80                     + "                + render_uri + '?bid=' + bid }};\n"
81                     + "}";
82 
83     public static final String AD_SELECTION_FROM_OUTCOMES_USE_CASE = "ad-selection-from-outcomes";
84 
85     public static final String AD_OUTCOME_SELECTION_WATERFALL_MEDIATION_TRUNCATION =
86             "waterfall-mediation-truncation";
87 
88     @VisibleForTesting
89     static final String AD_OUTCOME_SELECTION_WATERFALL_MEDIATION_TRUNCATION_JS =
90             "function selectOutcome(outcomes, selection_signals) {\n"
91                     + "    const outcome_1p = outcomes[0];\n"
92                     + "    const bid_floor = selection_signals.${bidFloor};\n"
93                     + "    return {'status': 0, 'result': (outcome_1p.bid >= bid_floor) ?"
94                     + " outcome_1p : null};\n"
95                     + "}";
96 
97     @VisibleForTesting static final String NAMED_PARAM_TEMPLATE = "\\$\\{%s\\}";
98     private static final Pattern PARAM_IDENTIFIER_REGEX_PATTERN =
99             Pattern.compile(String.format(NAMED_PARAM_TEMPLATE, "(.*?)"));
100     @NonNull private final Flags mFlags;
101 
PrebuiltLogicGenerator(@onNull Flags flags)102     public PrebuiltLogicGenerator(@NonNull Flags flags) {
103         Objects.requireNonNull(flags);
104 
105         mFlags = flags;
106     }
107 
108     /**
109      * Returns true if the given URI is in FLEDGE Ad Selection Prebuilt format
110      *
111      * @param decisionUri URI to check
112      * @return true if prebuilt URI, otherwise false
113      */
isPrebuiltUri(Uri decisionUri)114     public boolean isPrebuiltUri(Uri decisionUri) {
115         String scheme = decisionUri.getScheme();
116         boolean isPrebuilt = Objects.nonNull(scheme) && scheme.equals(AD_SELECTION_PREBUILT_SCHEMA);
117         sLogger.v("Checking if URI %s is of prebuilt schema: %s", decisionUri, isPrebuilt);
118         sLogger.v("Prebuilt enabled flag: %s", mFlags.getFledgeAdSelectionPrebuiltUriEnabled());
119         if (isPrebuilt && !mFlags.getFledgeAdSelectionPrebuiltUriEnabled()) {
120             sLogger.e(PREBUILT_FEATURE_IS_DISABLED);
121             throw new IllegalArgumentException(PREBUILT_FEATURE_IS_DISABLED);
122         }
123         return isPrebuilt;
124     }
125 
126     /**
127      * Returns the generated JS script from a valid prebuilt URI.
128      *
129      * @param prebuiltUri valid prebuilt URI. {@link IllegalArgumentException} is thrown if the
130      *     given URI is not valid or supported.
131      * @return JS script
132      */
jsScriptFromPrebuiltUri(Uri prebuiltUri)133     public String jsScriptFromPrebuiltUri(Uri prebuiltUri) {
134 
135         if (!isPrebuiltUri(prebuiltUri)) {
136             String err = String.format(UNKNOWN_PREBUILT_IDENTIFIER, prebuiltUri);
137             sLogger.e(err);
138             throw new IllegalArgumentException(err);
139         }
140         sLogger.v("Prebuilt enabled: %s", mFlags.getFledgeAdSelectionPrebuiltUriEnabled());
141         if (!mFlags.getFledgeAdSelectionPrebuiltUriEnabled()) {
142             sLogger.e(PREBUILT_FEATURE_IS_DISABLED);
143             throw new IllegalArgumentException(PREBUILT_FEATURE_IS_DISABLED);
144         }
145         sLogger.v("Generating JS for URI: %s", prebuiltUri);
146         String jsTemplate =
147                 getPrebuiltJsScriptTemplate(prebuiltUri.getHost(), prebuiltUri.getPath());
148         sLogger.v("Template found for URI %s:%n%s", prebuiltUri, jsTemplate);
149 
150         Set<String> requiredParams = calculateRequiredParameters(jsTemplate);
151         Set<String> queryParams = prebuiltUri.getQueryParameterNames();
152         sLogger.v("Required parameters are calculated: %s", requiredParams);
153         sLogger.v("Query parameters are calculated: %s", queryParams);
154 
155         crossValidateRequiredAndQueryParams(requiredParams, queryParams);
156 
157         for (String param : requiredParams) {
158             if (!prebuiltUri.getQueryParameterNames().contains(param)) {
159                 String err = String.format(MISSING_PREBUILT_PARAMS, param);
160                 sLogger.e(err);
161                 throw new IllegalArgumentException(err);
162             }
163 
164             jsTemplate =
165                     jsTemplate.replaceAll(
166                             String.format(NAMED_PARAM_TEMPLATE, param),
167                             prebuiltUri.getQueryParameter(param));
168         }
169         sLogger.i("Final prebuilt JS is generated:%n%s", jsTemplate);
170 
171         return jsTemplate;
172     }
173 
calculateRequiredParameters(String jsTemplate)174     private Set<String> calculateRequiredParameters(String jsTemplate) {
175         Set<String> requiredParameters = new HashSet<>();
176         Matcher matcher = PARAM_IDENTIFIER_REGEX_PATTERN.matcher(jsTemplate);
177         while (matcher.find()) {
178             requiredParameters.add(matcher.group(1));
179         }
180         return requiredParameters;
181     }
182 
getPrebuiltJsScriptTemplate(String prebuiltUseCase, String prebuiltName)183     private String getPrebuiltJsScriptTemplate(String prebuiltUseCase, String prebuiltName) {
184         sLogger.v("Use case is '%s', prebuilt name is '%s'.", prebuiltUseCase, prebuiltName);
185         switch (prebuiltUseCase) {
186             case AD_SELECTION_USE_CASE:
187                 sLogger.v("Use case matched with %s", AD_SELECTION_USE_CASE);
188                 return getPrebuiltJsScriptTemplateForAdSelection(prebuiltName);
189             case AD_SELECTION_FROM_OUTCOMES_USE_CASE:
190                 sLogger.v("Use case matched with %s", AD_SELECTION_FROM_OUTCOMES_USE_CASE);
191                 return getPrebuiltJsScriptTemplateForAdSelectionFromOutcome(prebuiltName);
192             default:
193                 String err = String.format(UNKNOWN_PREBUILT_IDENTIFIER, prebuiltUseCase);
194                 sLogger.e(err);
195                 throw new IllegalArgumentException(err);
196         }
197     }
198 
getPrebuiltJsScriptTemplateForAdSelection(String prebuiltName)199     private String getPrebuiltJsScriptTemplateForAdSelection(String prebuiltName) {
200         switch (prebuiltName) {
201             case "/" + AD_SELECTION_HIGHEST_BID_WINS + "/":
202                 sLogger.v("Prebuilt use case matched with %s", AD_SELECTION_HIGHEST_BID_WINS);
203                 return AD_SELECTION_HIGHEST_BID_WINS_JS;
204             default:
205                 String err = String.format(UNKNOWN_PREBUILT_IDENTIFIER, prebuiltName);
206                 sLogger.e(err);
207                 throw new IllegalArgumentException(err);
208         }
209     }
210 
getPrebuiltJsScriptTemplateForAdSelectionFromOutcome(String prebuiltName)211     private String getPrebuiltJsScriptTemplateForAdSelectionFromOutcome(String prebuiltName) {
212         switch (prebuiltName) {
213             case "/" + AD_OUTCOME_SELECTION_WATERFALL_MEDIATION_TRUNCATION + "/":
214                 sLogger.v(
215                         "Prebuilt use case matched with %s",
216                         AD_OUTCOME_SELECTION_WATERFALL_MEDIATION_TRUNCATION);
217                 return AD_OUTCOME_SELECTION_WATERFALL_MEDIATION_TRUNCATION_JS;
218             default:
219                 String err = String.format(UNKNOWN_PREBUILT_IDENTIFIER, prebuiltName);
220                 sLogger.e(err);
221                 throw new IllegalArgumentException(err);
222         }
223     }
224 
crossValidateRequiredAndQueryParams( Set<String> requiredParams, Set<String> queryParams)225     private void crossValidateRequiredAndQueryParams(
226             Set<String> requiredParams, Set<String> queryParams) {
227         Set<String> erroneousParams;
228         if (!(erroneousParams =
229                         requiredParams.stream()
230                                 .filter(p -> !queryParams.contains(p))
231                                 .collect(Collectors.toSet()))
232                 .isEmpty()) {
233             String err = String.format(MISSING_PREBUILT_PARAMS, erroneousParams);
234             sLogger.e(err);
235             throw new IllegalArgumentException(err);
236         }
237         if (!(erroneousParams =
238                         queryParams.stream()
239                                 .filter(p -> !requiredParams.contains(p))
240                                 .collect(Collectors.toSet()))
241                 .isEmpty()) {
242             String err = String.format(UNRECOGNIZED_PREBUILT_PARAMS, erroneousParams);
243             sLogger.e(err);
244             throw new IllegalArgumentException(err);
245         }
246     }
247 }
248