1 /*
2  * Copyright 2020 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 androidx.appsearch.app;
18 
19 import android.annotation.SuppressLint;
20 
21 import androidx.annotation.NonNull;
22 import androidx.annotation.RestrictTo;
23 import androidx.appsearch.annotation.CanIgnoreReturnValue;
24 import androidx.appsearch.exceptions.AppSearchException;
25 import androidx.appsearch.flags.FlaggedApi;
26 import androidx.appsearch.flags.Flags;
27 import androidx.appsearch.usagereporting.TakenAction;
28 import androidx.collection.ArraySet;
29 import androidx.core.util.Preconditions;
30 
31 import java.util.ArrayList;
32 import java.util.Arrays;
33 import java.util.Collection;
34 import java.util.Collections;
35 import java.util.List;
36 import java.util.Set;
37 
38 /**
39  * Encapsulates a request to index documents into an {@link AppSearchSession} database.
40  *
41  * <!--@exportToFramework:ifJetpack()-->
42  * <p>Documents added to the request can be instances of classes annotated with
43  * {@link androidx.appsearch.annotation.Document} or instances of
44  * {@link GenericDocument}.
45  * <!--@exportToFramework:else()-->
46  *
47  * @see AppSearchSession#putAsync
48  */
49 // TODO(b/384721898): Switch to JSpecify annotations
50 @SuppressWarnings("JSpecifyNullness")
51 public final class PutDocumentsRequest {
52     private final List<GenericDocument> mDocuments;
53 
54     private final List<GenericDocument> mTakenActions;
55 
PutDocumentsRequest(List<GenericDocument> documents, List<GenericDocument> takenActions)56     PutDocumentsRequest(List<GenericDocument> documents, List<GenericDocument> takenActions) {
57         mDocuments = documents;
58         mTakenActions = takenActions;
59     }
60 
61     /** Returns a list of {@link GenericDocument} objects that are part of this request. */
getGenericDocuments()62     public @NonNull List<GenericDocument> getGenericDocuments() {
63         return Collections.unmodifiableList(mDocuments);
64     }
65 
66     /**
67      * Returns a list of {@link GenericDocument} objects containing taken action metrics that are
68      * part of this request.
69      *
70      * <!--@exportToFramework:ifJetpack()-->
71      * <p>See {@link Builder#addTakenActions(TakenAction...)}.
72      * <!--@exportToFramework:else()
73      * <p>See {@link Builder#addTakenActionGenericDocuments(GenericDocument...)}.
74      * -->
75      */
76     @FlaggedApi(Flags.FLAG_ENABLE_PUT_DOCUMENTS_REQUEST_ADD_TAKEN_ACTIONS)
getTakenActionGenericDocuments()77     public @NonNull List<GenericDocument> getTakenActionGenericDocuments() {
78         return Collections.unmodifiableList(mTakenActions);
79     }
80 
81 
82     /** Builder for {@link PutDocumentsRequest} objects. */
83     public static final class Builder {
84         private ArrayList<GenericDocument> mDocuments = new ArrayList<>();
85         private ArrayList<GenericDocument> mTakenActions = new ArrayList<>();
86         private boolean mBuilt = false;
87 
88         /** Adds one or more {@link GenericDocument} objects to the request. */
89         @CanIgnoreReturnValue
addGenericDocuments(@onNull GenericDocument... documents)90         public @NonNull Builder addGenericDocuments(@NonNull GenericDocument... documents) {
91             Preconditions.checkNotNull(documents);
92             resetIfBuilt();
93             return addGenericDocuments(Arrays.asList(documents));
94         }
95 
96         /** Adds a collection of {@link GenericDocument} objects to the request. */
97         @CanIgnoreReturnValue
addGenericDocuments( @onNull Collection<? extends GenericDocument> documents)98         public @NonNull Builder addGenericDocuments(
99                 @NonNull Collection<? extends GenericDocument> documents) {
100             Preconditions.checkNotNull(documents);
101             resetIfBuilt();
102             mDocuments.addAll(documents);
103             return this;
104         }
105 
106 // @exportToFramework:startStrip()
107         /**
108          * Adds one or more annotated {@link androidx.appsearch.annotation.Document}
109          * documents to the request.
110          *
111          * @param documents annotated
112          *                    {@link androidx.appsearch.annotation.Document} documents.
113          * @throws AppSearchException if an error occurs converting a document class into a
114          *                            {@link GenericDocument}.
115          */
116         // Merged list available from getGenericDocuments()
117         @SuppressLint("MissingGetterMatchingBuilder")
118         @CanIgnoreReturnValue
addDocuments(@onNull Object... documents)119         public @NonNull Builder addDocuments(@NonNull Object... documents)
120                 throws AppSearchException {
121             Preconditions.checkNotNull(documents);
122             resetIfBuilt();
123             return addDocuments(Arrays.asList(documents));
124         }
125 
126         /**
127          * Adds a collection of annotated
128          * {@link androidx.appsearch.annotation.Document} documents to the request.
129          *
130          * @param documents annotated
131          *                    {@link androidx.appsearch.annotation.Document} documents.
132          * @throws AppSearchException if an error occurs converting a document into a
133          *                            {@link GenericDocument}.
134          */
135         // Merged list available from getGenericDocuments()
136         @SuppressLint("MissingGetterMatchingBuilder")
137         @CanIgnoreReturnValue
addDocuments(@onNull Collection<?> documents)138         public @NonNull Builder addDocuments(@NonNull Collection<?> documents)
139                 throws AppSearchException {
140             Preconditions.checkNotNull(documents);
141             resetIfBuilt();
142             List<GenericDocument> genericDocuments = new ArrayList<>(documents.size());
143             for (Object document : documents) {
144                 GenericDocument genericDocument = GenericDocument.fromDocumentClass(document);
145                 genericDocuments.add(genericDocument);
146             }
147             return addGenericDocuments(genericDocuments);
148         }
149 
150         /**
151          * Adds one or more {@link TakenAction} objects to the request.
152          *
153          * <p>Clients can construct {@link TakenAction} documents to report the user's actions on
154          * search results, and these actions can be used as signals to boost result ranking in
155          * future search requests. See {@link TakenAction} for more details.
156          *
157          * <p>Clients should report search and click actions together sorted by
158          * {@link TakenAction#getActionTimestampMillis} in chronological order.
159          * <p>For example, if there are 2 search actions, with 1 click action associated with the
160          * first and 2 click actions associated with the second, then clients should report
161          * [searchAction1, clickAction1, searchAction2, clickAction2, clickAction3].
162          *
163          * <p>Certain anonymized information about actions reported using this API may be uploaded
164          * using statsd and may be used to improve the quality of the search algorithms. Most of
165          * the information in this class is already non-identifiable, such as durations and its
166          * position in the result set. Identifiable information which you choose to provide, such
167          * as the query string, will be anonymized using techniques like Federated Analytics to
168          * ensure only the most frequently searched terms across the whole user population are
169          * retained and available for study.
170          *
171          * <p>You can alternatively use the {@link #addDocuments(Object...)} API with
172          * {@link TakenAction} document to retain the benefits of joining and using it on-device,
173          * without triggering any of the anonymized stats uploading described above.
174          *
175          * @param takenActions one or more {@link TakenAction} objects.
176          */
177         // Merged list available from getTakenActionGenericDocuments()
178         @SuppressWarnings("MissingGetterMatchingBuilder")
179         @CanIgnoreReturnValue
180         @ExperimentalAppSearchApi
addTakenActions( @onNull TakenAction... takenActions)181         public @NonNull Builder addTakenActions(
182                 @NonNull TakenAction... takenActions) throws AppSearchException {
183             Preconditions.checkNotNull(takenActions);
184             resetIfBuilt();
185             return addTakenActions(Arrays.asList(takenActions));
186         }
187 
188         /**
189          * Adds a collection of {@link TakenAction} objects to the request.
190          *
191          * @see #addTakenActions(TakenAction...)
192          *
193          * @param takenActions a collection of {@link TakenAction} objects.
194          */
195         // Merged list available from getTakenActionGenericDocuments()
196         @SuppressWarnings("MissingGetterMatchingBuilder")
197         @CanIgnoreReturnValue
198         @ExperimentalAppSearchApi
addTakenActions( @onNull Collection<? extends TakenAction> takenActions)199         public @NonNull Builder addTakenActions(
200                 @NonNull Collection<? extends TakenAction> takenActions)
201                 throws AppSearchException {
202             Preconditions.checkNotNull(takenActions);
203             resetIfBuilt();
204             List<GenericDocument> genericDocuments = new ArrayList<>(takenActions.size());
205             for (Object takenAction : takenActions) {
206                 GenericDocument genericDocument = GenericDocument.fromDocumentClass(takenAction);
207                 genericDocuments.add(genericDocument);
208             }
209             mTakenActions.addAll(genericDocuments);
210             return this;
211         }
212 // @exportToFramework:endStrip()
213 
214         /**
215          * Adds one or more {@link GenericDocument} objects containing taken action metrics to the
216          * request.
217          *
218          * <p>It is recommended to use taken action document classes in Jetpack library to construct
219          * taken action documents.
220          *
221          * <p>The document creation timestamp of the {@link GenericDocument} should be set to the
222          * actual action timestamp via {@link GenericDocument.Builder#setCreationTimestampMillis}.
223          *
224          * <p>Clients should report search and click actions together sorted by
225          * {@link GenericDocument#getCreationTimestampMillis} in chronological order.
226          * <p>For example, if there are 2 search actions, with 1 click action associated with the
227          * first and 2 click actions associated with the second, then clients should report
228          * [searchAction1, clickAction1, searchAction2, clickAction2, clickAction3].
229          *
230          * <p>Different types of taken actions and metrics to be collected by AppSearch:
231          * <ul>
232          *  <li>
233          *      Search action
234          *      <ul>
235          *          <li>actionType: LONG, the enum value of the action type.
236          *          <p>Requires to be {@code 1} for search actions.
237          *
238          *          <li>query: STRING, the user-entered search input (without any operators or
239          *          rewriting).
240          *
241          *          <li>fetchedResultCount: LONG, the number of {@link SearchResult} documents
242          *          fetched from AppSearch in this search action.
243          *      </ul>
244          *  </li>
245          *
246          *  <li>
247          *      Click action
248          *      <ul>
249          *          <li>actionType: LONG, the enum value of the action type.
250          *          <p>Requires to be {@code 2} for click actions.
251          *
252          *          <li>query: STRING, the user-entered search input (without any operators or
253          *          rewriting) that yielded the {@link SearchResult} on which the user took action.
254          *
255          *          <li>referencedQualifiedId: STRING, the qualified id of the {@link SearchResult}
256          *          document that the user takes action on.
257          *          <p>A qualified id is a string generated by package, database, namespace, and
258          *          document id. See
259          *          {@link androidx.appsearch.util.DocumentIdUtil#createQualifiedId} for more
260          *          details.
261          *
262          *          <li>resultRankInBlock: LONG, the rank of the {@link SearchResult} document among
263          *          the user-defined block.
264          *          <p>The client can define its own custom definition for block, for example,
265          *          corpus name, group, etc.
266          *          <p>For example, a client defines the block as corpus, and AppSearch returns 5
267          *          documents with corpus = ["corpus1", "corpus1", "corpus2", "corpus3", "corpus2"].
268          *          Then the block ranks of them = [1, 2, 1, 1, 2].
269          *          <p>If the client is not presenting the results in multiple blocks, they should
270          *          set this value to match resultRankGlobal.
271          *
272          *          <li>resultRankGlobal: LONG, the global rank of the {@link SearchResult}
273          *          document.
274          *          <p>Global rank reflects the order of {@link SearchResult} documents returned by
275          *          AppSearch.
276          *          <p>For example, AppSearch returns 2 pages with 10 {@link SearchResult} documents
277          *          for each page. Then the global ranks of them will be 1 to 10 for the first page,
278          *          and 11 to 20 for the second page.
279          *
280          *          <li>timeStayOnResultMillis: LONG, the time in milliseconds that user stays on
281          *          the {@link SearchResult} document after clicking it.
282          *      </ul>
283          *  </li>
284          * </ul>
285          *
286          * <p>Certain anonymized information about actions reported using this API may be uploaded
287          * using statsd and may be used to improve the quality of the search algorithms. Most of
288          * the information in this class is already non-identifiable, such as durations and its
289          * position in the result set. Identifiable information which you choose to provide, such
290          * as the query string, will be anonymized using techniques like Federated Analytics to
291          * ensure only the most frequently searched terms across the whole user population are
292          * retained and available for study.
293          *
294          * <p>You can alternatively use the {@link #addGenericDocuments(GenericDocument...)} API to
295          * retain the benefits of joining and using it on-device, without triggering any of the
296          * anonymized stats uploading described above.
297          *
298          * @param takenActionGenericDocuments one or more {@link GenericDocument} objects containing
299          *                                    taken action metric fields.
300          */
301         @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
302         @CanIgnoreReturnValue
303         @FlaggedApi(Flags.FLAG_ENABLE_PUT_DOCUMENTS_REQUEST_ADD_TAKEN_ACTIONS)
addTakenActionGenericDocuments( @onNull GenericDocument... takenActionGenericDocuments)304         public @NonNull Builder addTakenActionGenericDocuments(
305                 @NonNull GenericDocument... takenActionGenericDocuments)
306                 throws AppSearchException {
307             Preconditions.checkNotNull(takenActionGenericDocuments);
308             resetIfBuilt();
309             return addTakenActionGenericDocuments(Arrays.asList(takenActionGenericDocuments));
310         }
311 
312         /**
313          * Adds a collection of {@link GenericDocument} objects containing taken action metrics to
314          * the request.
315          *
316          * @see #addTakenActionGenericDocuments(GenericDocument...)
317          *
318          * @param takenActionGenericDocuments a collection of {@link GenericDocument} objects
319          *                                    containing taken action metric fields.
320          */
321         @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
322         @CanIgnoreReturnValue
323         @FlaggedApi(Flags.FLAG_ENABLE_PUT_DOCUMENTS_REQUEST_ADD_TAKEN_ACTIONS)
addTakenActionGenericDocuments(@onNull Collection<? extends GenericDocument> takenActionGenericDocuments)324         public @NonNull Builder addTakenActionGenericDocuments(@NonNull Collection<?
325                 extends GenericDocument> takenActionGenericDocuments) throws AppSearchException {
326             Preconditions.checkNotNull(takenActionGenericDocuments);
327             resetIfBuilt();
328             mTakenActions.addAll(takenActionGenericDocuments);
329             return this;
330         }
331 
332         /**
333          * Creates a new {@link PutDocumentsRequest} object.
334          *
335          * @throws IllegalArgumentException if there is any id collision between normal and action
336          *                                  documents.
337          */
build()338         public @NonNull PutDocumentsRequest build() {
339             mBuilt = true;
340 
341             // Verify there is no id collision between normal documents and action documents.
342             Set<String> idSet = new ArraySet<>();
343             for (int i = 0; i < mDocuments.size(); i++) {
344                 idSet.add(mDocuments.get(i).getId());
345             }
346             for (int i = 0; i < mTakenActions.size(); i++) {
347                 GenericDocument takenAction = mTakenActions.get(i);
348                 if (idSet.contains(takenAction.getId())) {
349                     throw new IllegalArgumentException("Document id " + takenAction.getId()
350                             + " cannot exist in both taken action and normal document");
351                 }
352             }
353 
354             return new PutDocumentsRequest(mDocuments, mTakenActions);
355         }
356 
resetIfBuilt()357         private void resetIfBuilt() {
358             if (mBuilt) {
359                 mDocuments = new ArrayList<>(mDocuments);
360                 mTakenActions = new ArrayList<>(mTakenActions);
361                 mBuilt = false;
362             }
363         }
364     }
365 }
366