1 /*
2  * Copyright (C) 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 package android.app.search;
17 
18 import android.annotation.CallbackExecutor;
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.SystemApi;
22 import android.app.search.ISearchCallback.Stub;
23 import android.content.Context;
24 import android.content.pm.ParceledListSlice;
25 import android.os.Binder;
26 import android.os.Bundle;
27 import android.os.IBinder;
28 import android.os.RemoteException;
29 import android.os.ServiceManager;
30 import android.os.SystemClock;
31 import android.util.ArrayMap;
32 import android.util.Log;
33 
34 import com.android.internal.annotations.GuardedBy;
35 
36 import dalvik.system.CloseGuard;
37 
38 import java.util.List;
39 import java.util.UUID;
40 import java.util.concurrent.Executor;
41 import java.util.concurrent.atomic.AtomicBoolean;
42 import java.util.function.Consumer;
43 
44 /**
45  * Client needs to create {@link SearchSession} object from in order to execute
46  * {@link #query(Query, Executor, Consumer)} method and share client side signals
47  * back to the service using {@link #notifyEvent(Query, SearchTargetEvent)}.
48  *
49  * <p>
50  * Usage: <pre> {@code
51  *
52  * class MyActivity {
53  *
54  *    void onCreate() {
55  *         mSearchSession.createSearchSession(searchContext)
56  *    }
57  *
58  *    void afterTextChanged(...) {
59  *        mSearchSession.query(...);
60  *    }
61  *
62  *    void onTouch(...) OR
63  *    void onStateTransitionStarted(...) OR
64  *    void onResume(...) OR
65  *    void onStop(...) {
66  *        mSearchSession.notifyEvent(event);
67  *    }
68  *
69  *    void onDestroy() {
70  *        mSearchSession.close();
71  *    }
72  *
73  * }</pre>
74  *
75  * @hide
76  */
77 @SystemApi
78 public final class SearchSession implements AutoCloseable {
79 
80     private static final String TAG = SearchSession.class.getSimpleName();
81     private static final boolean DEBUG = false;
82 
83     private final android.app.search.ISearchUiManager mInterface;
84     private final CloseGuard mCloseGuard = CloseGuard.get();
85     private final AtomicBoolean mIsClosed = new AtomicBoolean(false);
86 
87     private final SearchSessionId mSessionId;
88     private final IBinder mToken = new Binder();
89     @GuardedBy("itself")
90     private final ArrayMap<Callback, CallbackWrapper> mRegisteredCallbacks = new ArrayMap<>();
91 
92     /**
93      * Creates a new search ui client.
94      * <p>
95      * The caller should call {@link SearchSession#destroy()} to dispose the client once it
96      * no longer used.
97      *
98      * @param context       the {@link Context} of the user of this {@link SearchSession}.
99      * @param searchContext the search context.
100      */
101     // b/175668315 Create weak reference child objects to not leak context.
SearchSession(@onNull Context context, @NonNull SearchContext searchContext)102     SearchSession(@NonNull Context context, @NonNull SearchContext searchContext) {
103         IBinder b = ServiceManager.getService(Context.SEARCH_UI_SERVICE);
104         mInterface = android.app.search.ISearchUiManager.Stub.asInterface(b);
105         mSessionId = new SearchSessionId(
106                 context.getPackageName() + ":" + UUID.randomUUID().toString(), context.getUserId());
107         // b/175527717 whitelist possible clients of this API
108         searchContext.setPackageName(context.getPackageName());
109         try {
110             mInterface.createSearchSession(searchContext, mSessionId, mToken);
111         } catch (RemoteException e) {
112             Log.e(TAG, "Failed to search session", e);
113             e.rethrowFromSystemServer();
114         }
115 
116         mCloseGuard.open("SearchSession.close");
117     }
118 
119     /**
120      * Notifies the search service of an search target event (e.g., user interaction
121      * and lifecycle event of the search surface).
122      *
123      * {@see SearchTargetEvent}
124      *
125      * @param query input object associated with the event.
126      * @param event The {@link SearchTargetEvent} that represents the search target event.
127      */
notifyEvent(@onNull Query query, @NonNull SearchTargetEvent event)128     public void notifyEvent(@NonNull Query query, @NonNull SearchTargetEvent event) {
129         if (mIsClosed.get()) {
130             throw new IllegalStateException("This client has already been destroyed.");
131         }
132 
133         try {
134             mInterface.notifyEvent(mSessionId, query, event);
135         } catch (RemoteException e) {
136             Log.e(TAG, "Failed to notify event", e);
137             e.rethrowFromSystemServer();
138         }
139     }
140 
141     /**
142      * Calls consumer with list of {@link SearchTarget}s based on the input query.
143      *
144      * @param input query object to be used for the request.
145      * @param callbackExecutor The callback executor to use when calling the callback.
146      * @param callback The callback to return the list of search targets.
147      */
148     @Nullable
query(@onNull Query input, @NonNull @CallbackExecutor Executor callbackExecutor, @NonNull Consumer<List<SearchTarget>> callback)149     public void query(@NonNull Query input,
150             @NonNull @CallbackExecutor Executor callbackExecutor,
151             @NonNull Consumer<List<SearchTarget>> callback) {
152         if (mIsClosed.get()) {
153             throw new IllegalStateException("This client has already been destroyed.");
154         }
155 
156         try {
157 
158             mInterface.query(mSessionId, input, new CallbackWrapper(callbackExecutor, callback));
159         } catch (RemoteException e) {
160             Log.e(TAG, "Failed to sort targets", e);
161             e.rethrowFromSystemServer();
162         }
163     }
164     /**
165      * Request the search ui service provide continuous updates of {@link SearchTarget} list
166      * via the provided callback to render for zero state, until the given callback is
167      * unregistered. Zero state means when user entered search ui but not issued any query yet.
168      *
169      * @see SearchSession.Callback#onTargetsAvailable(List).
170      *
171      * @param callbackExecutor The callback executor to use when calling the callback.
172      * @param callback The Callback to be called when updates of search targets for zero state
173      *                 are available.
174      */
registerEmptyQueryResultUpdateCallback( @onNull @allbackExecutor Executor callbackExecutor, @NonNull Callback callback)175     public void registerEmptyQueryResultUpdateCallback(
176             @NonNull @CallbackExecutor Executor callbackExecutor,
177             @NonNull Callback callback) {
178         synchronized (mRegisteredCallbacks) {
179             if (mIsClosed.get()) {
180                 throw new IllegalStateException("This client has already been destroyed.");
181             }
182             if (mRegisteredCallbacks.containsKey(callback)) {
183                 // Skip if this callback is already registered
184                 return;
185             }
186             try {
187                 final CallbackWrapper callbackWrapper = new CallbackWrapper(callbackExecutor,
188                         callback::onTargetsAvailable);
189                 mInterface.registerEmptyQueryResultUpdateCallback(mSessionId, callbackWrapper);
190                 mRegisteredCallbacks.put(callback, callbackWrapper);
191             } catch (RemoteException e) {
192                 Log.e(TAG, "Failed to register for empty query result updates", e);
193                 e.rethrowAsRuntimeException();
194             }
195         }
196     }
197 
198     /**
199      * Requests the search ui service to stop providing continuous updates of {@link SearchTarget}
200      * to the provided callback for zero state until the callback is re-registered. Zero state
201      * means when user entered search ui but not issued any query yet.
202      *
203      * @see {@link SearchSession#registerEmptyQueryResultUpdateCallback(Executor, Callback)}
204      * @param callback The callback to be unregistered.
205      */
unregisterEmptyQueryResultUpdateCallback( @onNull Callback callback)206     public void unregisterEmptyQueryResultUpdateCallback(
207             @NonNull Callback callback) {
208         synchronized (mRegisteredCallbacks) {
209             if (mIsClosed.get()) {
210                 throw new IllegalStateException("This client has already been destroyed.");
211             }
212 
213             if (!mRegisteredCallbacks.containsKey(callback)) {
214                 // Skip if this callback was never registered
215                 return;
216             }
217             try {
218                 final CallbackWrapper callbackWrapper = mRegisteredCallbacks.remove(callback);
219                 mInterface.unregisterEmptyQueryResultUpdateCallback(mSessionId, callbackWrapper);
220             } catch (RemoteException e) {
221                 Log.e(TAG, "Failed to unregister for empty query result updates", e);
222                 e.rethrowAsRuntimeException();
223             }
224         }
225     }
226 
227     /**
228      * Destroys the client and unregisters the callback. Any method on this class after this call
229      * will throw {@link IllegalStateException}.
230      *
231      * @deprecated
232      * @removed
233      */
234     @Deprecated
destroy()235     public void destroy() {
236         if (!mIsClosed.getAndSet(true)) {
237             mCloseGuard.close();
238 
239             // Do destroy;
240             try {
241                 mInterface.destroySearchSession(mSessionId);
242             } catch (RemoteException e) {
243                 Log.e(TAG, "Failed to notify search target event", e);
244                 e.rethrowFromSystemServer();
245             }
246         } else {
247             throw new IllegalStateException("This client has already been destroyed.");
248         }
249     }
250 
251     @Override
finalize()252     protected void finalize() {
253         try {
254             if (mCloseGuard != null) {
255                 mCloseGuard.warnIfOpen();
256             }
257             if (!mIsClosed.get()) {
258                 destroy();
259             }
260         } finally {
261             try {
262                 super.finalize();
263             } catch (Throwable throwable) {
264                 throwable.printStackTrace();
265             }
266         }
267     }
268 
269     /**
270      * Destroys the client and unregisters the callback. Any method on this class after this call
271      * will throw {@link IllegalStateException}.
272      *
273      */
274     @Override
close()275     public void close() {
276         try {
277             finalize();
278         } catch (Throwable throwable) {
279             throwable.printStackTrace();
280         }
281     }
282 
283     /**
284      *  Callback for receiving {@link SearchTarget} updates for zero state. Zero state
285      *  means when user entered search ui but not issued any query yet.
286      */
287     public interface Callback {
288 
289         /**
290          * Called when a new set of {@link SearchTarget} are available for zero state.
291          * @param targets Sorted list of search targets.
292          */
onTargetsAvailable(@onNull List<SearchTarget> targets)293         void onTargetsAvailable(@NonNull List<SearchTarget> targets);
294     }
295 
296     static class CallbackWrapper extends Stub {
297 
298         private final Consumer<List<SearchTarget>> mCallback;
299         private final Executor mExecutor;
300 
CallbackWrapper(@onNull Executor callbackExecutor, @NonNull Consumer<List<SearchTarget>> callback)301         CallbackWrapper(@NonNull Executor callbackExecutor,
302                 @NonNull Consumer<List<SearchTarget>> callback) {
303             mCallback = callback;
304             mExecutor = callbackExecutor;
305         }
306 
307         @Override
onResult(ParceledListSlice result)308         public void onResult(ParceledListSlice result) {
309             final long identity = Binder.clearCallingIdentity();
310             try {
311                 if (DEBUG) {
312                     Log.d(TAG, "CallbackWrapper.onResult result=" + result.getList());
313                 }
314                 List<SearchTarget> list = result.getList();
315                 if (list.size() > 0) {
316                     Bundle bundle = list.get(0).getExtras();
317                     if (bundle != null) {
318                         bundle.putLong("key_ipc_start", SystemClock.elapsedRealtime());
319                     }
320                 }
321                 mExecutor.execute(() -> mCallback.accept(list));
322             } finally {
323                 Binder.restoreCallingIdentity(identity);
324             }
325         }
326     }
327 }
328