1 /*
2  * Copyright (C) 2018 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 android.view.textclassifier;
18 
19 import android.annotation.NonNull;
20 import android.annotation.WorkerThread;
21 import android.view.textclassifier.SelectionEvent.InvocationMethod;
22 
23 import com.android.internal.annotations.GuardedBy;
24 import com.android.internal.util.Preconditions;
25 
26 import java.util.Objects;
27 import java.util.function.Supplier;
28 
29 import sun.misc.Cleaner;
30 
31 /**
32  * Session-aware TextClassifier.
33  */
34 @WorkerThread
35 final class TextClassificationSession implements TextClassifier {
36 
37     private static final String LOG_TAG = "TextClassificationSession";
38 
39     private final TextClassifier mDelegate;
40     private final SelectionEventHelper mEventHelper;
41     private final TextClassificationSessionId mSessionId;
42     private final TextClassificationContext mClassificationContext;
43     private final Cleaner mCleaner;
44 
45     private final Object mLock = new Object();
46 
47     @GuardedBy("mLock")
48     private boolean mDestroyed;
49 
TextClassificationSession(TextClassificationContext context, TextClassifier delegate)50     TextClassificationSession(TextClassificationContext context, TextClassifier delegate) {
51         mClassificationContext = Objects.requireNonNull(context);
52         mDelegate = Objects.requireNonNull(delegate);
53         mSessionId = new TextClassificationSessionId();
54         mEventHelper = new SelectionEventHelper(mSessionId, mClassificationContext);
55         initializeRemoteSession();
56         // This ensures destroy() is called if the client forgot to do so.
57         mCleaner = Cleaner.create(this, new CleanerRunnable(mEventHelper, mDelegate));
58     }
59 
60     @Override
suggestSelection(TextSelection.Request request)61     public TextSelection suggestSelection(TextSelection.Request request) {
62         return checkDestroyedAndRun(() -> mDelegate.suggestSelection(request));
63     }
64 
initializeRemoteSession()65     private void initializeRemoteSession() {
66         if (mDelegate instanceof SystemTextClassifier) {
67             ((SystemTextClassifier) mDelegate).initializeRemoteSession(
68                     mClassificationContext, mSessionId);
69         }
70     }
71 
72     @Override
classifyText(TextClassification.Request request)73     public TextClassification classifyText(TextClassification.Request request) {
74         return checkDestroyedAndRun(() -> mDelegate.classifyText(request));
75     }
76 
77     @Override
generateLinks(TextLinks.Request request)78     public TextLinks generateLinks(TextLinks.Request request) {
79         return checkDestroyedAndRun(() -> mDelegate.generateLinks(request));
80     }
81 
82     @Override
suggestConversationActions(ConversationActions.Request request)83     public ConversationActions suggestConversationActions(ConversationActions.Request request) {
84         return checkDestroyedAndRun(() -> mDelegate.suggestConversationActions(request));
85     }
86 
87     @Override
detectLanguage(TextLanguage.Request request)88     public TextLanguage detectLanguage(TextLanguage.Request request) {
89         return checkDestroyedAndRun(() -> mDelegate.detectLanguage(request));
90     }
91 
92     @Override
getMaxGenerateLinksTextLength()93     public int getMaxGenerateLinksTextLength() {
94         return checkDestroyedAndRun(mDelegate::getMaxGenerateLinksTextLength);
95     }
96 
97     @Override
onSelectionEvent(SelectionEvent event)98     public void onSelectionEvent(SelectionEvent event) {
99         checkDestroyedAndRun(() -> {
100             try {
101                 if (mEventHelper.sanitizeEvent(event)) {
102                     mDelegate.onSelectionEvent(event);
103                 }
104             } catch (Exception e) {
105                 // Avoid crashing for event reporting.
106                 Log.e(LOG_TAG, "Error reporting text classifier selection event", e);
107             }
108             return null;
109         });
110     }
111 
112     @Override
onTextClassifierEvent(TextClassifierEvent event)113     public void onTextClassifierEvent(TextClassifierEvent event) {
114         checkDestroyedAndRun(() -> {
115             try {
116                 event.mHiddenTempSessionId = mSessionId;
117                 mDelegate.onTextClassifierEvent(event);
118             } catch (Exception e) {
119                 // Avoid crashing for event reporting.
120                 Log.e(LOG_TAG, "Error reporting text classifier event", e);
121             }
122             return null;
123         });
124     }
125 
126     @Override
destroy()127     public void destroy() {
128         synchronized (mLock) {
129             if (!mDestroyed) {
130                 mCleaner.clean();
131                 mDestroyed = true;
132             }
133         }
134     }
135 
136     @Override
isDestroyed()137     public boolean isDestroyed() {
138         synchronized (mLock) {
139             return mDestroyed;
140         }
141     }
142 
143     /**
144      * Check whether the TextClassification Session was destroyed before and after the actual API
145      * invocation, and return response if not.
146      *
147      * @param responseSupplier a Supplier that represents a TextClassifier call
148      * @return the response of the TextClassifier call
149      * @throws IllegalStateException if this TextClassification session was destroyed before the
150      *                               call returned
151      * @see #isDestroyed()
152      * @see #destroy()
153      */
checkDestroyedAndRun(Supplier<T> responseSupplier)154     private <T> T checkDestroyedAndRun(Supplier<T> responseSupplier) {
155         if (!isDestroyed()) {
156             T response = responseSupplier.get();
157             synchronized (mLock) {
158                 if (!mDestroyed) {
159                     return response;
160                 }
161             }
162         }
163         throw new IllegalStateException(
164                 "This TextClassification session has been destroyed");
165     }
166 
167     /**
168      * Helper class for updating SelectionEvent fields.
169      */
170     private static final class SelectionEventHelper {
171 
172         private final TextClassificationSessionId mSessionId;
173         private final TextClassificationContext mContext;
174 
175         @InvocationMethod
176         private int mInvocationMethod = SelectionEvent.INVOCATION_UNKNOWN;
177         private SelectionEvent mPrevEvent;
178         private SelectionEvent mSmartEvent;
179         private SelectionEvent mStartEvent;
180 
SelectionEventHelper( TextClassificationSessionId sessionId, TextClassificationContext context)181         SelectionEventHelper(
182                 TextClassificationSessionId sessionId, TextClassificationContext context) {
183             mSessionId = Objects.requireNonNull(sessionId);
184             mContext = Objects.requireNonNull(context);
185         }
186 
187         /**
188          * Updates the necessary fields in the event for the current session.
189          *
190          * @return true if the event should be reported. false if the event should be ignored
191          */
sanitizeEvent(SelectionEvent event)192         boolean sanitizeEvent(SelectionEvent event) {
193             updateInvocationMethod(event);
194             modifyAutoSelectionEventType(event);
195 
196             if (event.getEventType() != SelectionEvent.EVENT_SELECTION_STARTED
197                     && mStartEvent == null) {
198                 Log.d(LOG_TAG, "Selection session not yet started. Ignoring event");
199                 return false;
200             }
201 
202             final long now = System.currentTimeMillis();
203             switch (event.getEventType()) {
204                 case SelectionEvent.EVENT_SELECTION_STARTED:
205                     Preconditions.checkArgument(
206                             event.getAbsoluteEnd() == event.getAbsoluteStart() + 1);
207                     event.setSessionId(mSessionId);
208                     mStartEvent = event;
209                     break;
210                 case SelectionEvent.EVENT_SMART_SELECTION_SINGLE:  // fall through
211                 case SelectionEvent.EVENT_SMART_SELECTION_MULTI:   // fall through
212                 case SelectionEvent.EVENT_AUTO_SELECTION:
213                     mSmartEvent = event;
214                     break;
215                 case SelectionEvent.ACTION_ABANDON:
216                 case SelectionEvent.ACTION_OVERTYPE:
217                     if (mPrevEvent != null) {
218                         event.setEntityType(mPrevEvent.getEntityType());
219                     }
220                     break;
221                 case SelectionEvent.EVENT_SELECTION_MODIFIED:
222                     if (mPrevEvent != null
223                             && mPrevEvent.getAbsoluteStart() == event.getAbsoluteStart()
224                             && mPrevEvent.getAbsoluteEnd() == event.getAbsoluteEnd()) {
225                         // Selection did not change. Ignore event.
226                         return false;
227                     }
228                     break;
229                 default:
230                     // do nothing.
231             }
232 
233             event.setEventTime(now);
234             if (mStartEvent != null) {
235                 event.setSessionId(mStartEvent.getSessionId())
236                         .setDurationSinceSessionStart(now - mStartEvent.getEventTime())
237                         .setStart(event.getAbsoluteStart() - mStartEvent.getAbsoluteStart())
238                         .setEnd(event.getAbsoluteEnd() - mStartEvent.getAbsoluteStart());
239             }
240             if (mSmartEvent != null) {
241                 event.setResultId(mSmartEvent.getResultId())
242                         .setSmartStart(
243                                 mSmartEvent.getAbsoluteStart() - mStartEvent.getAbsoluteStart())
244                         .setSmartEnd(mSmartEvent.getAbsoluteEnd() - mStartEvent.getAbsoluteStart());
245             }
246             if (mPrevEvent != null) {
247                 event.setDurationSincePreviousEvent(now - mPrevEvent.getEventTime())
248                         .setEventIndex(mPrevEvent.getEventIndex() + 1);
249             }
250             mPrevEvent = event;
251             return true;
252         }
253 
endSession()254         void endSession() {
255             mPrevEvent = null;
256             mSmartEvent = null;
257             mStartEvent = null;
258         }
259 
updateInvocationMethod(SelectionEvent event)260         private void updateInvocationMethod(SelectionEvent event) {
261             event.setTextClassificationSessionContext(mContext);
262             if (event.getInvocationMethod() == SelectionEvent.INVOCATION_UNKNOWN) {
263                 event.setInvocationMethod(mInvocationMethod);
264             } else {
265                 mInvocationMethod = event.getInvocationMethod();
266             }
267         }
268 
modifyAutoSelectionEventType(SelectionEvent event)269         private void modifyAutoSelectionEventType(SelectionEvent event) {
270             switch (event.getEventType()) {
271                 case SelectionEvent.EVENT_SMART_SELECTION_SINGLE:  // fall through
272                 case SelectionEvent.EVENT_SMART_SELECTION_MULTI:  // fall through
273                 case SelectionEvent.EVENT_AUTO_SELECTION:
274                     if (SelectionSessionLogger.isPlatformLocalTextClassifierSmartSelection(
275                             event.getResultId())) {
276                         if (event.getAbsoluteEnd() - event.getAbsoluteStart() > 1) {
277                             event.setEventType(SelectionEvent.EVENT_SMART_SELECTION_MULTI);
278                         } else {
279                             event.setEventType(SelectionEvent.EVENT_SMART_SELECTION_SINGLE);
280                         }
281                     } else {
282                         event.setEventType(SelectionEvent.EVENT_AUTO_SELECTION);
283                     }
284                     return;
285                 default:
286                     return;
287             }
288         }
289     }
290 
291     // We use a static nested class here to avoid retaining the object reference of the outer
292     // class. Otherwise. the Cleaner would never be triggered.
293     private static class CleanerRunnable implements Runnable {
294         @NonNull
295         private final SelectionEventHelper mEventHelper;
296         @NonNull
297         private final TextClassifier mDelegate;
298 
CleanerRunnable( @onNull SelectionEventHelper eventHelper, @NonNull TextClassifier delegate)299         CleanerRunnable(
300                 @NonNull SelectionEventHelper eventHelper, @NonNull TextClassifier delegate) {
301             mEventHelper = Objects.requireNonNull(eventHelper);
302             mDelegate = Objects.requireNonNull(delegate);
303         }
304 
305         @Override
run()306         public void run() {
307             mEventHelper.endSession();
308             mDelegate.destroy();
309         }
310     }
311 }
312