blob: 885bd2a39580493e1a0149344ea726af39cc59f7 [file] [log] [blame]
Felipe Leme1dfa9a02018-10-17 17:24:37 -07001/*
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 */
Felipe Leme749b8892018-12-03 16:30:30 -080016package android.view.contentcapture;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070017
Felipe Lemed32d8f6f2019-02-15 10:25:33 -080018import static android.view.contentcapture.ContentCaptureHelper.sDebug;
19import static android.view.contentcapture.ContentCaptureHelper.sVerbose;
Felipe Lemebe002d82019-01-23 10:22:32 -080020
Felipe Lemed32d8f6f2019-02-15 10:25:33 -080021import android.annotation.IntDef;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070022import android.annotation.NonNull;
23import android.annotation.Nullable;
Felipe Leme91ddeca2019-01-24 18:01:58 -080024import android.annotation.SystemApi;
Felipe Lemee348dc32018-11-05 12:35:29 -080025import android.annotation.SystemService;
Felipe Leme19652c02019-02-04 13:01:29 -080026import android.annotation.TestApi;
Felipe Leme87a9dc92018-12-18 14:28:07 -080027import android.annotation.UiThread;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070028import android.content.ComponentName;
Felipe Leme326f15a2019-02-19 09:42:24 -080029import android.content.ContentCaptureOptions;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070030import android.content.Context;
Felipe Leme88eae3b2018-11-07 15:11:56 -080031import android.os.Handler;
Felipe Lemee348dc32018-11-05 12:35:29 -080032import android.os.IBinder;
Felipe Leme34ccedf2019-01-17 13:42:35 -080033import android.os.Looper;
Perumaal Saddabba2019-01-04 16:43:35 -080034import android.os.RemoteException;
Felipe Lemee348dc32018-11-05 12:35:29 -080035import android.util.Log;
Felipe Leme1af85ea2019-01-16 13:23:40 -080036import android.view.contentcapture.ContentCaptureSession.FlushReason;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070037
Adam He6079d152019-01-10 11:37:17 -080038import com.android.internal.annotations.GuardedBy;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070039import com.android.internal.util.Preconditions;
Perumaal Saddabba2019-01-04 16:43:35 -080040import com.android.internal.util.SyncResultReceiver;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070041
Felipe Lemee348dc32018-11-05 12:35:29 -080042import java.io.PrintWriter;
Felipe Lemed32d8f6f2019-02-15 10:25:33 -080043import java.lang.annotation.Retention;
44import java.lang.annotation.RetentionPolicy;
Felipe Leme1dfa9a02018-10-17 17:24:37 -070045
Felipe Lemeecb08be2018-11-27 15:48:47 -080046/**
Felipe Lemed49d52c2019-02-15 09:48:20 -080047 * TODO(b/123577059): add javadocs / mention it can be null
Felipe Lemeecb08be2018-11-27 15:48:47 -080048 */
49@SystemService(Context.CONTENT_CAPTURE_MANAGER_SERVICE)
50public final class ContentCaptureManager {
Felipe Leme1dfa9a02018-10-17 17:24:37 -070051
Felipe Leme749b8892018-12-03 16:30:30 -080052 private static final String TAG = ContentCaptureManager.class.getSimpleName();
Felipe Lemee348dc32018-11-05 12:35:29 -080053
Felipe Lemebb0c2a22019-01-25 17:29:29 -080054 /** @hide */
55 public static final int RESULT_CODE_TRUE = 1;
56 /** @hide */
57 public static final int RESULT_CODE_FALSE = 2;
58 /** @hide */
59 public static final int RESULT_CODE_NOT_SERVICE = -1;
60
Perumaal Saddabba2019-01-04 16:43:35 -080061 /**
62 * Timeout for calls to system_server.
63 */
64 private static final int SYNC_CALLS_TIMEOUT_MS = 5000;
65
Felipe Leme14ef4612019-02-07 12:24:38 -080066 /**
67 * DeviceConfig property used by {@code com.android.server.SystemServer} on start to decide
68 * whether the Content Capture service should be created or not
69 *
70 * <p>By default it should *NOT* be set (or set to {@code "default"}, so the decision is based
71 * on whether the OEM provides an implementation for the service), but it can be overridden to:
72 *
73 * <ul>
74 * <li>Provide a "kill switch" so OEMs can disable it remotely in case of emergency (when
75 * it's set to {@code "false"}).
76 * <li>Enable the CTS tests to be run on AOSP builds (when it's set to {@code "true"}).
77 * </ul>
78 *
79 * @hide
80 */
81 @TestApi
82 public static final String DEVICE_CONFIG_PROPERTY_SERVICE_EXPLICITLY_ENABLED =
83 "service_explicitly_enabled";
84
Felipe Lemed32d8f6f2019-02-15 10:25:33 -080085 /**
86 * Maximum number of events that are buffered before sent to the app.
87 *
88 * @hide
89 */
90 @TestApi
91 public static final String DEVICE_CONFIG_PROPERTY_MAX_BUFFER_SIZE = "max_buffer_size";
92
93 /**
94 * Frequency (in ms) of buffer flushes when no events are received.
95 *
96 * @hide
97 */
98 @TestApi
99 public static final String DEVICE_CONFIG_PROPERTY_IDLE_FLUSH_FREQUENCY = "idle_flush_frequency";
100
101 /**
102 * Frequency (in ms) of buffer flushes when no events are received and the last one was a
103 * text change event.
104 *
105 * @hide
106 */
107 @TestApi
108 public static final String DEVICE_CONFIG_PROPERTY_TEXT_CHANGE_FLUSH_FREQUENCY =
109 "text_change_flush_frequency";
110
111 /**
112 * Size of events that are logging on {@code dump}.
113 *
114 * <p>Set it to {@code 0} or less to disable history.
115 *
116 * @hide
117 */
118 @TestApi
119 public static final String DEVICE_CONFIG_PROPERTY_LOG_HISTORY_SIZE = "log_history_size";
120
121 /**
122 * Sets the logging level for {@code logcat} statements.
123 *
124 * <p>Valid values are: {@link #LOGGING_LEVEL_OFF}, {@value #LOGGING_LEVEL_DEBUG}, and
125 * {@link #LOGGING_LEVEL_VERBOSE}.
126 *
127 * @hide
128 */
129 @TestApi
130 public static final String DEVICE_CONFIG_PROPERTY_LOGGING_LEVEL = "logging_level";
131
Felipe Lemee764fa22019-02-21 16:45:35 -0800132 /**
133 * Sets how long (in ms) the service is bound while idle.
134 *
135 * <p>Use {@code 0} to keep it permanently bound.
136 *
137 * @hide
138 */
139 public static final String DEVICE_CONFIG_PROPERTY_IDLE_UNBIND_TIMEOUT = "idle_unbind_timeout";
Felipe Lemed32d8f6f2019-02-15 10:25:33 -0800140
141 /** @hide */
142 @TestApi
143 public static final int LOGGING_LEVEL_OFF = 0;
144
145 /** @hide */
146 @TestApi
147 public static final int LOGGING_LEVEL_DEBUG = 1;
148
149 /** @hide */
150 @TestApi
151 public static final int LOGGING_LEVEL_VERBOSE = 2;
152
153 /** @hide */
154 @IntDef(flag = false, value = {
155 LOGGING_LEVEL_OFF,
156 LOGGING_LEVEL_DEBUG,
157 LOGGING_LEVEL_VERBOSE
158 })
159 @Retention(RetentionPolicy.SOURCE)
160 public @interface LoggingLevel {}
161
Felipe Leme326f15a2019-02-19 09:42:24 -0800162
163 /** @hide */
164 public static final int DEFAULT_MAX_BUFFER_SIZE = 100;
165 /** @hide */
166 public static final int DEFAULT_IDLE_FLUSHING_FREQUENCY_MS = 5_000;
167 /** @hide */
168 public static final int DEFAULT_TEXT_CHANGE_FLUSHING_FREQUENCY_MS = 1_000;
169 /** @hide */
170 public static final int DEFAULT_LOG_HISTORY_SIZE = 10;
171
Adam He6079d152019-01-10 11:37:17 -0800172 private final Object mLock = new Object();
173
Felipe Lemeb18e3172018-11-27 10:33:41 -0800174 @NonNull
Felipe Lemee348dc32018-11-05 12:35:29 -0800175 private final Context mContext;
176
Felipe Lemed49d52c2019-02-15 09:48:20 -0800177 @NonNull
Felipe Leme749b8892018-12-03 16:30:30 -0800178 private final IContentCaptureManager mService;
Felipe Lemee348dc32018-11-05 12:35:29 -0800179
Felipe Leme326f15a2019-02-19 09:42:24 -0800180 @NonNull
181 final ContentCaptureOptions mOptions;
182
Adam He6079d152019-01-10 11:37:17 -0800183 // Flags used for starting session.
184 @GuardedBy("mLock")
185 private int mFlags;
186
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800187 // TODO(b/119220549): use UI Thread directly (as calls are one-way) or a shared thread / handler
Felipe Lemeb18e3172018-11-27 10:33:41 -0800188 // held at the Application level
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800189 @NonNull
Felipe Leme88eae3b2018-11-07 15:11:56 -0800190 private final Handler mHandler;
191
Adam He6079d152019-01-10 11:37:17 -0800192 @GuardedBy("mLock")
Felipe Leme87a9dc92018-12-18 14:28:07 -0800193 private MainContentCaptureSession mMainSession;
Felipe Leme4017b202018-12-10 12:13:31 -0800194
Felipe Lemee348dc32018-11-05 12:35:29 -0800195 /** @hide */
Felipe Leme749b8892018-12-03 16:30:30 -0800196 public ContentCaptureManager(@NonNull Context context,
Felipe Leme326f15a2019-02-19 09:42:24 -0800197 @NonNull IContentCaptureManager service, @NonNull ContentCaptureOptions options) {
Felipe Lemed49d52c2019-02-15 09:48:20 -0800198 mContext = Preconditions.checkNotNull(context, "context cannot be null");
199 mService = Preconditions.checkNotNull(service, "service cannot be null");
Felipe Leme326f15a2019-02-19 09:42:24 -0800200 mOptions = Preconditions.checkNotNull(options, "options cannot be null");
Felipe Lemee348dc32018-11-05 12:35:29 -0800201
Felipe Leme326f15a2019-02-19 09:42:24 -0800202 ContentCaptureHelper.setLoggingLevel(mOptions.loggingLevel);
Felipe Lemed32d8f6f2019-02-15 10:25:33 -0800203
204 if (sVerbose) Log.v(TAG, "Constructor for " + context.getPackageName());
205
Felipe Leme34ccedf2019-01-17 13:42:35 -0800206 // TODO(b/119220549): we might not even need a handler, as the IPCs are oneway. But if we
207 // do, then we should optimize it to run the tests after the Choreographer finishes the most
208 // important steps of the frame.
209 mHandler = Handler.createAsync(Looper.getMainLooper());
Felipe Leme88eae3b2018-11-07 15:11:56 -0800210 }
211
Felipe Leme7a534082018-11-05 15:03:04 -0800212 /**
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800213 * Gets the main session associated with the context.
Felipe Leme88eae3b2018-11-07 15:11:56 -0800214 *
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800215 * <p>By default there's just one (associated with the activity lifecycle), but apps could
Felipe Leme87a9dc92018-12-18 14:28:07 -0800216 * explicitly add more using
217 * {@link ContentCaptureSession#createContentCaptureSession(ContentCaptureContext)}.
Felipe Leme88eae3b2018-11-07 15:11:56 -0800218 *
219 * @hide
220 */
221 @NonNull
Felipe Leme87a9dc92018-12-18 14:28:07 -0800222 @UiThread
223 public MainContentCaptureSession getMainContentCaptureSession() {
Adam He6079d152019-01-10 11:37:17 -0800224 synchronized (mLock) {
225 if (mMainSession == null) {
Felipe Leme609991d2019-01-30 16:27:24 -0800226 mMainSession = new MainContentCaptureSession(mContext, this, mHandler, mService);
Felipe Lemed32d8f6f2019-02-15 10:25:33 -0800227 if (sVerbose) Log.v(TAG, "getMainContentCaptureSession(): created " + mMainSession);
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800228 }
Adam He6079d152019-01-10 11:37:17 -0800229 return mMainSession;
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800230 }
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800231 }
232
233 /** @hide */
Felipe Leme3fe6e922019-02-04 17:52:27 -0800234 @UiThread
Felipe Lemeb0da18f2019-02-22 15:10:02 -0800235 public void onActivityCreated(@NonNull IBinder applicationToken,
Adam He328c0e32019-01-03 15:19:22 -0800236 @NonNull ComponentName activityComponent, int flags) {
Adam He6079d152019-01-10 11:37:17 -0800237 synchronized (mLock) {
238 mFlags |= flags;
239 getMainContentCaptureSession().start(applicationToken, activityComponent, mFlags);
240 }
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800241 }
242
243 /** @hide */
Felipe Leme3fe6e922019-02-04 17:52:27 -0800244 @UiThread
Felipe Lemeb0da18f2019-02-22 15:10:02 -0800245 public void onActivityResumed() {
246 getMainContentCaptureSession().notifySessionLifecycle(/* started= */ true);
247 }
248
249 /** @hide */
250 @UiThread
251 public void onActivityPaused() {
252 getMainContentCaptureSession().notifySessionLifecycle(/* started= */ false);
253 }
254
255 /** @hide */
256 @UiThread
257 public void onActivityDestroyed() {
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800258 getMainContentCaptureSession().destroy();
Felipe Leme88eae3b2018-11-07 15:11:56 -0800259 }
260
261 /**
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800262 * Flushes the content of all sessions.
Felipe Leme88eae3b2018-11-07 15:11:56 -0800263 *
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800264 * <p>Typically called by {@code Activity} when it's paused / resumed.
Felipe Leme88eae3b2018-11-07 15:11:56 -0800265 *
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800266 * @hide
Felipe Leme88eae3b2018-11-07 15:11:56 -0800267 */
Felipe Leme3fe6e922019-02-04 17:52:27 -0800268 @UiThread
Felipe Leme1af85ea2019-01-16 13:23:40 -0800269 public void flush(@FlushReason int reason) {
270 getMainContentCaptureSession().flush(reason);
Felipe Leme88eae3b2018-11-07 15:11:56 -0800271 }
272
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700273 /**
Felipe Lemeecb08be2018-11-27 15:48:47 -0800274 * Returns the component name of the system service that is consuming the captured events for
275 * the current user.
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700276 */
277 @Nullable
Felipe Lemeecb08be2018-11-27 15:48:47 -0800278 public ComponentName getServiceComponentName() {
Felipe Leme91ddeca2019-01-24 18:01:58 -0800279 if (!isContentCaptureEnabled()) return null;
280
Perumaal Saddabba2019-01-04 16:43:35 -0800281 final SyncResultReceiver resultReceiver = new SyncResultReceiver(SYNC_CALLS_TIMEOUT_MS);
Perumaal Saddabba2019-01-04 16:43:35 -0800282 try {
Felipe Lemeeffa5f42019-01-28 14:59:46 -0800283 mService.getServiceComponentName(resultReceiver);
Perumaal Saddabba2019-01-04 16:43:35 -0800284 return resultReceiver.getParcelableResult();
285 } catch (RemoteException e) {
Perumaal Saddabba2019-01-04 16:43:35 -0800286 throw e.rethrowFromSystemServer();
287 }
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700288 }
289
290 /**
Felipe Lemee348dc32018-11-05 12:35:29 -0800291 * Checks whether content capture is enabled for this activity.
Felipe Leme91ddeca2019-01-24 18:01:58 -0800292 *
293 * <p>There are many reasons it could be disabled, such as:
294 * <ul>
295 * <li>App itself disabled content capture through {@link #setContentCaptureEnabled(boolean)}.
296 * <li>Service disabled content capture for this specific activity.
297 * <li>Service disabled content capture for all activities of this package.
298 * <li>Service disabled content capture globally.
299 * <li>User disabled content capture globally (through Settings).
300 * <li>OEM disabled content capture globally.
301 * <li>Transient errors.
302 * </ul>
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700303 */
304 public boolean isContentCaptureEnabled() {
Felipe Leme609991d2019-01-30 16:27:24 -0800305 final MainContentCaptureSession mainSession;
Adam He6079d152019-01-10 11:37:17 -0800306 synchronized (mLock) {
Felipe Leme609991d2019-01-30 16:27:24 -0800307 mainSession = mMainSession;
Adam He6079d152019-01-10 11:37:17 -0800308 }
Felipe Leme609991d2019-01-30 16:27:24 -0800309 // The main session is only set when the activity starts, so we need to return true until
310 // then.
311 if (mainSession != null && mainSession.isDisabled()) return false;
312
313 return true;
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700314 }
315
316 /**
Felipe Leme284ad1c2018-11-15 18:16:12 -0800317 * Called by apps to explicitly enable or disable content capture.
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700318 *
319 * <p><b>Note: </b> this call is not persisted accross reboots, so apps should typically call
320 * it on {@link android.app.Activity#onCreate(android.os.Bundle, android.os.PersistableBundle)}.
321 */
Felipe Leme6b3a55c2018-11-13 17:14:03 -0800322 public void setContentCaptureEnabled(boolean enabled) {
Felipe Lemed32d8f6f2019-02-15 10:25:33 -0800323 if (sDebug) {
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800324 Log.d(TAG, "setContentCaptureEnabled(): setting to " + enabled + " for " + mContext);
325 }
326
Adam He6079d152019-01-10 11:37:17 -0800327 synchronized (mLock) {
328 mFlags |= enabled ? 0 : ContentCaptureContext.FLAG_DISABLED_BY_APP;
329 }
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800330 }
331
332 /**
Felipe Leme91ddeca2019-01-24 18:01:58 -0800333 * Gets whether Content Capture is enabled for the given user.
334 *
335 * <p>This method is typically used by the Content Capture Service settings page, so it can
336 * provide a toggle to enable / disable it.
337 *
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800338 * @throws SecurityException if caller is not the app that owns the Content Capture service
339 * associated with the user.
340 *
Felipe Leme91ddeca2019-01-24 18:01:58 -0800341 * @hide
342 */
343 @SystemApi
Felipe Leme19652c02019-02-04 13:01:29 -0800344 @TestApi
Felipe Leme91ddeca2019-01-24 18:01:58 -0800345 public boolean isContentCaptureFeatureEnabled() {
Felipe Leme91ddeca2019-01-24 18:01:58 -0800346 final SyncResultReceiver resultReceiver = new SyncResultReceiver(SYNC_CALLS_TIMEOUT_MS);
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800347 final int resultCode;
Felipe Leme91ddeca2019-01-24 18:01:58 -0800348 try {
349 mService.isContentCaptureFeatureEnabled(resultReceiver);
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800350 resultCode = resultReceiver.getIntResult();
Felipe Leme91ddeca2019-01-24 18:01:58 -0800351 } catch (RemoteException e) {
Felipe Leme91ddeca2019-01-24 18:01:58 -0800352 throw e.rethrowFromSystemServer();
353 }
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800354 switch (resultCode) {
355 case RESULT_CODE_TRUE:
356 return true;
357 case RESULT_CODE_FALSE:
358 return false;
359 case RESULT_CODE_NOT_SERVICE:
360 throw new SecurityException("caller is not user's ContentCapture service");
361 default:
Felipe Leme72e83d82019-02-13 14:51:17 -0800362 Log.wtf(TAG, "received invalid result: " + resultCode);
363 return false;
Felipe Lemebb0c2a22019-01-25 17:29:29 -0800364 }
Felipe Leme91ddeca2019-01-24 18:01:58 -0800365 }
366
367 /**
Adam He3d0409b2019-01-15 14:22:04 -0800368 * Called by the app to request the Content Capture service to remove user-data associated with
Felipe Lemeaa5088e2018-12-10 14:53:58 -0800369 * some context.
370 *
371 * @param request object specifying what user data should be removed.
372 */
373 public void removeUserData(@NonNull UserDataRemovalRequest request) {
Adam He3d0409b2019-01-15 14:22:04 -0800374 Preconditions.checkNotNull(request);
375
376 try {
Felipe Lemef2aa0d22019-01-28 10:38:46 -0800377 mService.removeUserData(request);
Adam He3d0409b2019-01-15 14:22:04 -0800378 } catch (RemoteException e) {
379 e.rethrowFromSystemServer();
380 }
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700381 }
382
Felipe Lemee348dc32018-11-05 12:35:29 -0800383 /** @hide */
384 public void dump(String prefix, PrintWriter pw) {
Felipe Lemed49d52c2019-02-15 09:48:20 -0800385 pw.print(prefix); pw.println("ContentCaptureManager");
386 final String prefix2 = prefix + " ";
Adam He6079d152019-01-10 11:37:17 -0800387 synchronized (mLock) {
Felipe Lemed49d52c2019-02-15 09:48:20 -0800388 pw.print(prefix2); pw.print("isContentCaptureEnabled(): ");
Felipe Leme609991d2019-01-30 16:27:24 -0800389 pw.println(isContentCaptureEnabled());
Felipe Leme326f15a2019-02-19 09:42:24 -0800390 pw.print(prefix2); pw.print("Debug: "); pw.print(sDebug);
Felipe Lemed32d8f6f2019-02-15 10:25:33 -0800391 pw.print(" Verbose: "); pw.println(sVerbose);
Felipe Leme326f15a2019-02-19 09:42:24 -0800392 pw.print(prefix2); pw.print("Context: "); pw.println(mContext);
393 pw.print(prefix2); pw.print("User: "); pw.println(mContext.getUserId());
394 pw.print(prefix2); pw.print("Service: "); pw.println(mService);
395 pw.print(prefix2); pw.print("Flags: "); pw.println(mFlags);
396 pw.print(prefix2); pw.print("Options: "); mOptions.dumpShort(pw); pw.println();
Adam He6079d152019-01-10 11:37:17 -0800397 if (mMainSession != null) {
Felipe Lemed49d52c2019-02-15 09:48:20 -0800398 final String prefix3 = prefix2 + " ";
399 pw.print(prefix2); pw.println("Main session:");
400 mMainSession.dump(prefix3, pw);
Adam He6079d152019-01-10 11:37:17 -0800401 } else {
Felipe Lemed49d52c2019-02-15 09:48:20 -0800402 pw.print(prefix2); pw.println("No sessions");
Adam He6079d152019-01-10 11:37:17 -0800403 }
Felipe Lemee348dc32018-11-05 12:35:29 -0800404 }
405 }
Felipe Leme1dfa9a02018-10-17 17:24:37 -0700406}