| /* |
| * Copyright (C) 2018 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 android.view.contentcapture; |
| |
| import static android.view.contentcapture.ContentCaptureHelper.sDebug; |
| import static android.view.contentcapture.ContentCaptureHelper.sVerbose; |
| import static android.view.contentcapture.ContentCaptureHelper.toSet; |
| |
| import android.annotation.IntDef; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.SystemApi; |
| import android.annotation.SystemService; |
| import android.annotation.TestApi; |
| import android.annotation.UiThread; |
| import android.content.ComponentName; |
| import android.content.ContentCaptureOptions; |
| import android.content.Context; |
| import android.os.Handler; |
| import android.os.IBinder; |
| import android.os.Looper; |
| import android.os.RemoteException; |
| import android.os.ServiceManager; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewStructure; |
| import android.view.contentcapture.ContentCaptureSession.FlushReason; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.util.Preconditions; |
| import com.android.internal.util.SyncResultReceiver; |
| |
| import java.io.PrintWriter; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.ArrayList; |
| import java.util.Set; |
| |
| /** |
| * Content capture is mechanism used to let apps notify the Android system of events associated with |
| * views. |
| * |
| * <p>Before using this manager, you should check if it's available. Example: |
| * <pre><code> |
| * ContentCaptureManager mgr = context.getSystemService(ContentCaptureManager.class); |
| * if (mgr != null && mgr.isContentCaptureEnabled()) { |
| * // ... |
| * } |
| * </code></pre> |
| * |
| * <p>To support content capture, you must notifiy the Android system of the following events: |
| * |
| * <ul> |
| * <li>When a visible view is laid out, call |
| * {@link ContentCaptureSession#notifyViewAppeared(ViewStructure)}. |
| * <li>When a view becomes invisible or is removed from the view hierarchy, call |
| * {@link ContentCaptureSession#notifyViewDisappeared(android.view.autofill.AutofillId)}. |
| * <li>When the view represents text and the text value changed, call {@link |
| * ContentCaptureSession#notifyViewTextChanged(android.view.autofill.AutofillId, CharSequence)}. |
| * </ul> |
| * |
| * <p>You can get a blank content capture structure using |
| * {@link ContentCaptureSession#newViewStructure(View)}, then populate its relevant fields. |
| * Here's an example of the relevant methods for an {@code EditText}-like view: |
| * |
| * <pre><code> |
| * public class MyEditText extends View { |
| * |
| * private void populateContentCaptureStructure(@NonNull ViewStructure structure) { |
| * structure.setText(getText(), getSelectionStart(), getSelectionEnd()); |
| * structure.setHint(getHint()); |
| * structure.setInputType(getInputType()); |
| * // set other properties like setTextIdEntry(), setTextLines(), setTextStyle(), |
| * // setMinTextEms(), setMaxTextEms(), setMaxTextLength() |
| * } |
| * |
| * private void onTextChanged() { |
| * if (isLaidOut() && isTextEditable()) { |
| * ContentCaptureManager mgr = mContext.getSystemService(ContentCaptureManager.class); |
| * if (cm != null && cm.isContentCaptureEnabled()) { |
| * ContentCaptureSession session = getContentCaptureSession(); |
| * if (session != null) { |
| * session.notifyViewTextChanged(getAutofillId(), getText()); |
| * } |
| * } |
| * } |
| * </code></pre> |
| * |
| * <p>The main integration point with content capture is the {@link ContentCaptureSession}. A "main" |
| * session is automatically created by the Android system when content capture is enabled for the |
| * activity. The session could have a {@link ContentCaptureContext} to provide more contextual info |
| * about it, such as the locus associated with the view hierarchy |
| * (see {@link android.content.LocusId} for more info about locus). By default, the main session |
| * doesn't have a {@code ContentCaptureContext}, but you can change it after its created. Example: |
| * |
| * <pre><code> |
| * protected void onCreate(Bundle savedInstanceState) { |
| * // Initialize view structure |
| * ContentCaptureSession session = rootView.getContentCaptureSession(); |
| * if (session != null) { |
| * session.setContentCaptureContext(ContentCaptureContext.forLocusId("chat_UserA_UserB")); |
| * } |
| * } |
| * </code></pre> |
| * |
| * <p>If your activity contains view hierarchies with a different contextual meaning, you should |
| * created child sessions for each view hierarchy root. For example, if your activity is a browser, |
| * you could use the main session for the main URL being rendered, then child sessions for each |
| * {@code IFRAME}: |
| * |
| * <pre><code> |
| * ContentCaptureSession mMainSession; |
| * |
| * protected void onCreate(Bundle savedInstanceState) { |
| * // Initialize view structure... |
| * mMainSession = rootView.getContentCaptureSession(); |
| * if (mMainSession != null) { |
| * mMainSession.setContentCaptureContext( |
| * ContentCaptureContext.forLocusId("https://example.com")); |
| * } |
| * } |
| * |
| * private void loadIFrame(View iframeRootView, String url) { |
| * if (mMainSession != null) { |
| * ContentCaptureSession iFrameSession = mMainSession.newChild( |
| * ContentCaptureContext.forLocusId(url)); |
| * } |
| * iframeRootView.setContentCaptureSession(iFrameSession); |
| * } |
| * // Load iframe... |
| * } |
| * </code></pre> |
| * |
| */ |
| @SystemService(Context.CONTENT_CAPTURE_MANAGER_SERVICE) |
| public final class ContentCaptureManager { |
| |
| private static final String TAG = ContentCaptureManager.class.getSimpleName(); |
| |
| /** @hide */ |
| public static final int RESULT_CODE_OK = 0; |
| /** @hide */ |
| public static final int RESULT_CODE_TRUE = 1; |
| /** @hide */ |
| public static final int RESULT_CODE_FALSE = 2; |
| /** @hide */ |
| public static final int RESULT_CODE_SECURITY_EXCEPTION = -1; |
| |
| /** |
| * Timeout for calls to system_server. |
| */ |
| private static final int SYNC_CALLS_TIMEOUT_MS = 5000; |
| |
| /** |
| * DeviceConfig property used by {@code com.android.server.SystemServer} on start to decide |
| * whether the content capture service should be created or not |
| * |
| * <p>By default it should *NOT* be set (or set to {@code "default"}, so the decision is based |
| * on whether the OEM provides an implementation for the service), but it can be overridden to: |
| * |
| * <ul> |
| * <li>Provide a "kill switch" so OEMs can disable it remotely in case of emergency (when |
| * it's set to {@code "false"}). |
| * <li>Enable the CTS tests to be run on AOSP builds (when it's set to {@code "true"}). |
| * </ul> |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_SERVICE_EXPLICITLY_ENABLED = |
| "service_explicitly_enabled"; |
| |
| /** |
| * Maximum number of events that are buffered before sent to the app. |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_MAX_BUFFER_SIZE = "max_buffer_size"; |
| |
| /** |
| * Frequency (in ms) of buffer flushes when no events are received. |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_IDLE_FLUSH_FREQUENCY = "idle_flush_frequency"; |
| |
| /** |
| * Frequency (in ms) of buffer flushes when no events are received and the last one was a |
| * text change event. |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_TEXT_CHANGE_FLUSH_FREQUENCY = |
| "text_change_flush_frequency"; |
| |
| /** |
| * Size of events that are logging on {@code dump}. |
| * |
| * <p>Set it to {@code 0} or less to disable history. |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_LOG_HISTORY_SIZE = "log_history_size"; |
| |
| /** |
| * Sets the logging level for {@code logcat} statements. |
| * |
| * <p>Valid values are: {@link #LOGGING_LEVEL_OFF}, {@value #LOGGING_LEVEL_DEBUG}, and |
| * {@link #LOGGING_LEVEL_VERBOSE}. |
| * |
| * @hide |
| */ |
| @TestApi |
| public static final String DEVICE_CONFIG_PROPERTY_LOGGING_LEVEL = "logging_level"; |
| |
| /** |
| * Sets how long (in ms) the service is bound while idle. |
| * |
| * <p>Use {@code 0} to keep it permanently bound. |
| * |
| * @hide |
| */ |
| public static final String DEVICE_CONFIG_PROPERTY_IDLE_UNBIND_TIMEOUT = "idle_unbind_timeout"; |
| |
| /** @hide */ |
| @TestApi |
| public static final int LOGGING_LEVEL_OFF = 0; |
| |
| /** @hide */ |
| @TestApi |
| public static final int LOGGING_LEVEL_DEBUG = 1; |
| |
| /** @hide */ |
| @TestApi |
| public static final int LOGGING_LEVEL_VERBOSE = 2; |
| |
| /** @hide */ |
| @IntDef(flag = false, value = { |
| LOGGING_LEVEL_OFF, |
| LOGGING_LEVEL_DEBUG, |
| LOGGING_LEVEL_VERBOSE |
| }) |
| @Retention(RetentionPolicy.SOURCE) |
| public @interface LoggingLevel {} |
| |
| |
| /** @hide */ |
| public static final int DEFAULT_MAX_BUFFER_SIZE = 100; |
| /** @hide */ |
| public static final int DEFAULT_IDLE_FLUSHING_FREQUENCY_MS = 5_000; |
| /** @hide */ |
| public static final int DEFAULT_TEXT_CHANGE_FLUSHING_FREQUENCY_MS = 1_000; |
| /** @hide */ |
| public static final int DEFAULT_LOG_HISTORY_SIZE = 10; |
| |
| private final Object mLock = new Object(); |
| |
| @NonNull |
| private final Context mContext; |
| |
| @NonNull |
| private final IContentCaptureManager mService; |
| |
| @NonNull |
| final ContentCaptureOptions mOptions; |
| |
| // Flags used for starting session. |
| @GuardedBy("mLock") |
| private int mFlags; |
| |
| // TODO(b/119220549): use UI Thread directly (as calls are one-way) or a shared thread / handler |
| // held at the Application level |
| @NonNull |
| private final Handler mHandler; |
| |
| @GuardedBy("mLock") |
| private MainContentCaptureSession mMainSession; |
| |
| /** @hide */ |
| public interface ContentCaptureClient { |
| /** |
| * Gets the component name of the client. |
| */ |
| @NonNull |
| ComponentName contentCaptureClientGetComponentName(); |
| } |
| |
| /** @hide */ |
| public ContentCaptureManager(@NonNull Context context, |
| @NonNull IContentCaptureManager service, @NonNull ContentCaptureOptions options) { |
| mContext = Preconditions.checkNotNull(context, "context cannot be null"); |
| mService = Preconditions.checkNotNull(service, "service cannot be null"); |
| mOptions = Preconditions.checkNotNull(options, "options cannot be null"); |
| |
| ContentCaptureHelper.setLoggingLevel(mOptions.loggingLevel); |
| |
| if (sVerbose) Log.v(TAG, "Constructor for " + context.getPackageName()); |
| |
| // TODO(b/119220549): we might not even need a handler, as the IPCs are oneway. But if we |
| // do, then we should optimize it to run the tests after the Choreographer finishes the most |
| // important steps of the frame. |
| mHandler = Handler.createAsync(Looper.getMainLooper()); |
| } |
| |
| /** |
| * Gets the main session associated with the context. |
| * |
| * <p>By default there's just one (associated with the activity lifecycle), but apps could |
| * explicitly add more using |
| * {@link ContentCaptureSession#createContentCaptureSession(ContentCaptureContext)}. |
| * |
| * @hide |
| */ |
| @NonNull |
| @UiThread |
| public MainContentCaptureSession getMainContentCaptureSession() { |
| synchronized (mLock) { |
| if (mMainSession == null) { |
| mMainSession = new MainContentCaptureSession(mContext, this, mHandler, mService); |
| if (sVerbose) Log.v(TAG, "getMainContentCaptureSession(): created " + mMainSession); |
| } |
| return mMainSession; |
| } |
| } |
| |
| /** @hide */ |
| @UiThread |
| public void onActivityCreated(@NonNull IBinder applicationToken, |
| @NonNull ComponentName activityComponent, int flags) { |
| if (mOptions.lite) return; |
| synchronized (mLock) { |
| mFlags |= flags; |
| getMainContentCaptureSession().start(applicationToken, activityComponent, mFlags); |
| } |
| } |
| |
| /** @hide */ |
| @UiThread |
| public void onActivityResumed() { |
| if (mOptions.lite) return; |
| getMainContentCaptureSession().notifySessionLifecycle(/* started= */ true); |
| } |
| |
| /** @hide */ |
| @UiThread |
| public void onActivityPaused() { |
| if (mOptions.lite) return; |
| getMainContentCaptureSession().notifySessionLifecycle(/* started= */ false); |
| } |
| |
| /** @hide */ |
| @UiThread |
| public void onActivityDestroyed() { |
| if (mOptions.lite) return; |
| getMainContentCaptureSession().destroy(); |
| } |
| |
| /** |
| * Flushes the content of all sessions. |
| * |
| * <p>Typically called by {@code Activity} when it's paused / resumed. |
| * |
| * @hide |
| */ |
| @UiThread |
| public void flush(@FlushReason int reason) { |
| if (mOptions.lite) return; |
| getMainContentCaptureSession().flush(reason); |
| } |
| |
| /** |
| * Returns the component name of the system service that is consuming the captured events for |
| * the current user. |
| */ |
| @Nullable |
| public ComponentName getServiceComponentName() { |
| if (!isContentCaptureEnabled() && !mOptions.lite) return null; |
| |
| final SyncResultReceiver resultReceiver = new SyncResultReceiver(SYNC_CALLS_TIMEOUT_MS); |
| try { |
| mService.getServiceComponentName(resultReceiver); |
| return resultReceiver.getParcelableResult(); |
| } catch (RemoteException e) { |
| throw e.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Gets the (optional) intent used to launch the service-specific settings. |
| * |
| * <p>This method is static because it's called by Settings, which might not be whitelisted |
| * for content capture (in which case the ContentCaptureManager on its context would be null). |
| * |
| * @hide |
| */ |
| // TODO: use "lite" options as it's done by activities from the content capture service |
| @Nullable |
| public static ComponentName getServiceSettingsComponentName() { |
| final IBinder binder = ServiceManager |
| .checkService(Context.CONTENT_CAPTURE_MANAGER_SERVICE); |
| if (binder == null) return null; |
| |
| final IContentCaptureManager service = IContentCaptureManager.Stub.asInterface(binder); |
| final SyncResultReceiver resultReceiver = new SyncResultReceiver(SYNC_CALLS_TIMEOUT_MS); |
| try { |
| service.getServiceSettingsActivity(resultReceiver); |
| final int resultCode = resultReceiver.getIntResult(); |
| if (resultCode == RESULT_CODE_SECURITY_EXCEPTION) { |
| throw new SecurityException(resultReceiver.getStringResult()); |
| } |
| return resultReceiver.getParcelableResult(); |
| } catch (RemoteException e) { |
| throw e.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Checks whether content capture is enabled for this activity. |
| */ |
| public boolean isContentCaptureEnabled() { |
| if (mOptions.lite) return false; |
| |
| final MainContentCaptureSession mainSession; |
| synchronized (mLock) { |
| mainSession = mMainSession; |
| } |
| // The main session is only set when the activity starts, so we need to return true until |
| // then. |
| if (mainSession != null && mainSession.isDisabled()) return false; |
| |
| return true; |
| } |
| |
| /** |
| * Gets the list of conditions for when content capture should be allowed. |
| * |
| * <p>This method is typically used by web browsers so they don't generate unnecessary content |
| * capture events for some websites. |
| * |
| * @return list of conditions, or {@code null} if there isn't any restriction |
| * (in which case content capture events should always be generated). If the list is empty, |
| * then it should not generate any event at all. |
| */ |
| @Nullable |
| public Set<ContentCaptureCondition> getContentCaptureConditions() { |
| // NOTE: we could cache the conditions on ContentCaptureOptions, but then it would be stick |
| // to the lifetime of the app. OTOH, by dynamically calling the server every time, we allow |
| // the service to fine tune how long-lived apps (like browsers) are whitelisted. |
| if (!isContentCaptureEnabled() && !mOptions.lite) return null; |
| |
| final SyncResultReceiver resultReceiver = syncRun( |
| (r) -> mService.getContentCaptureConditions(mContext.getPackageName(), r)); |
| |
| final ArrayList<ContentCaptureCondition> result = resultReceiver |
| .getParcelableListResult(); |
| return toSet(result); |
| } |
| |
| /** |
| * Called by apps to explicitly enable or disable content capture. |
| * |
| * <p><b>Note: </b> this call is not persisted accross reboots, so apps should typically call |
| * it on {@link android.app.Activity#onCreate(android.os.Bundle, android.os.PersistableBundle)}. |
| */ |
| public void setContentCaptureEnabled(boolean enabled) { |
| if (sDebug) { |
| Log.d(TAG, "setContentCaptureEnabled(): setting to " + enabled + " for " + mContext); |
| } |
| |
| MainContentCaptureSession mainSession; |
| synchronized (mLock) { |
| if (enabled) { |
| mFlags &= ~ContentCaptureContext.FLAG_DISABLED_BY_APP; |
| } else { |
| mFlags |= ContentCaptureContext.FLAG_DISABLED_BY_APP; |
| } |
| mainSession = mMainSession; |
| } |
| if (mainSession != null) { |
| mainSession.setDisabled(!enabled); |
| } |
| } |
| |
| /** |
| * Gets whether content capture is enabled for the given user. |
| * |
| * <p>This method is typically used by the content capture service settings page, so it can |
| * provide a toggle to enable / disable it. |
| * |
| * @throws SecurityException if caller is not the app that owns the content capture service |
| * associated with the user. |
| * |
| * @hide |
| */ |
| @SystemApi |
| @TestApi |
| public boolean isContentCaptureFeatureEnabled() { |
| final SyncResultReceiver resultReceiver = syncRun( |
| (r) -> mService.isContentCaptureFeatureEnabled(r)); |
| final int resultCode = resultReceiver.getIntResult(); |
| switch (resultCode) { |
| case RESULT_CODE_TRUE: |
| return true; |
| case RESULT_CODE_FALSE: |
| return false; |
| default: |
| Log.wtf(TAG, "received invalid result: " + resultCode); |
| return false; |
| } |
| } |
| |
| /** |
| * Called by the app to remove content capture data associated with some context. |
| * |
| * @param request object specifying what data should be removed. |
| */ |
| public void removeData(@NonNull DataRemovalRequest request) { |
| Preconditions.checkNotNull(request); |
| |
| try { |
| mService.removeData(request); |
| } catch (RemoteException e) { |
| e.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Runs a sync method in the service, properly handling exceptions. |
| * |
| * @throws SecurityException if caller is not allowed to execute the method. |
| */ |
| @NonNull |
| private SyncResultReceiver syncRun(@NonNull MyRunnable r) { |
| final SyncResultReceiver resultReceiver = new SyncResultReceiver(SYNC_CALLS_TIMEOUT_MS); |
| try { |
| r.run(resultReceiver); |
| final int resultCode = resultReceiver.getIntResult(); |
| if (resultCode == RESULT_CODE_SECURITY_EXCEPTION) { |
| throw new SecurityException(resultReceiver.getStringResult()); |
| } |
| return resultReceiver; |
| } catch (RemoteException e) { |
| throw e.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** @hide */ |
| public void dump(String prefix, PrintWriter pw) { |
| pw.print(prefix); pw.println("ContentCaptureManager"); |
| final String prefix2 = prefix + " "; |
| synchronized (mLock) { |
| pw.print(prefix2); pw.print("isContentCaptureEnabled(): "); |
| pw.println(isContentCaptureEnabled()); |
| pw.print(prefix2); pw.print("Debug: "); pw.print(sDebug); |
| pw.print(" Verbose: "); pw.println(sVerbose); |
| pw.print(prefix2); pw.print("Context: "); pw.println(mContext); |
| pw.print(prefix2); pw.print("User: "); pw.println(mContext.getUserId()); |
| pw.print(prefix2); pw.print("Service: "); pw.println(mService); |
| pw.print(prefix2); pw.print("Flags: "); pw.println(mFlags); |
| pw.print(prefix2); pw.print("Options: "); mOptions.dumpShort(pw); pw.println(); |
| if (mMainSession != null) { |
| final String prefix3 = prefix2 + " "; |
| pw.print(prefix2); pw.println("Main session:"); |
| mMainSession.dump(prefix3, pw); |
| } else { |
| pw.print(prefix2); pw.println("No sessions"); |
| } |
| } |
| } |
| |
| private interface MyRunnable { |
| void run(@NonNull SyncResultReceiver receiver) throws RemoteException; |
| } |
| } |