1 /*
2  * Copyright 2023 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.camera.integration.core;
18 
19 import static androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA;
20 import static androidx.camera.testing.impl.FileUtil.canDeviceWriteToMediaStore;
21 import static androidx.camera.testing.impl.FileUtil.createFolder;
22 import static androidx.camera.testing.impl.FileUtil.createParentFolder;
23 import static androidx.camera.testing.impl.FileUtil.generateVideoFileOutputOptions;
24 import static androidx.camera.testing.impl.FileUtil.generateVideoMediaStoreOptions;
25 import static androidx.camera.testing.impl.FileUtil.getAbsolutePathFromUri;
26 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED;
27 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
28 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE;
29 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE;
30 import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE;
31 
32 import static com.google.common.base.Preconditions.checkNotNull;
33 
34 import android.app.NotificationChannel;
35 import android.app.NotificationManager;
36 import android.app.PendingIntent;
37 import android.content.ContentValues;
38 import android.content.Intent;
39 import android.media.MediaScannerConnection;
40 import android.net.Uri;
41 import android.os.Binder;
42 import android.os.Build;
43 import android.os.Environment;
44 import android.os.IBinder;
45 import android.os.SystemClock;
46 import android.provider.MediaStore;
47 import android.util.Log;
48 import android.view.View;
49 import android.widget.RemoteViews;
50 import android.widget.Toast;
51 
52 import androidx.annotation.RequiresApi;
53 import androidx.annotation.VisibleForTesting;
54 import androidx.camera.core.ImageAnalysis;
55 import androidx.camera.core.ImageCapture;
56 import androidx.camera.core.ImageCaptureException;
57 import androidx.camera.core.UseCase;
58 import androidx.camera.core.UseCaseGroup;
59 import androidx.camera.lifecycle.ProcessCameraProvider;
60 import androidx.camera.video.FileOutputOptions;
61 import androidx.camera.video.MediaStoreOutputOptions;
62 import androidx.camera.video.OutputOptions;
63 import androidx.camera.video.PendingRecording;
64 import androidx.camera.video.Recorder;
65 import androidx.camera.video.Recording;
66 import androidx.camera.video.VideoCapture;
67 import androidx.camera.video.VideoRecordEvent;
68 import androidx.core.app.NotificationCompat;
69 import androidx.core.content.ContextCompat;
70 import androidx.core.util.Consumer;
71 import androidx.lifecycle.LifecycleService;
72 
73 import com.google.common.util.concurrent.ListenableFuture;
74 
75 import org.jspecify.annotations.NonNull;
76 import org.jspecify.annotations.Nullable;
77 
78 import java.io.File;
79 import java.text.Format;
80 import java.text.SimpleDateFormat;
81 import java.util.ArrayList;
82 import java.util.Calendar;
83 import java.util.Collection;
84 import java.util.Collections;
85 import java.util.HashMap;
86 import java.util.HashSet;
87 import java.util.List;
88 import java.util.Locale;
89 import java.util.Map;
90 import java.util.Set;
91 import java.util.concurrent.CountDownLatch;
92 import java.util.concurrent.ExecutionException;
93 import java.util.concurrent.atomic.AtomicInteger;
94 
95 /**
96  * A service used to test background UseCases binding and camera operations.
97  */
98 public class CameraXService extends LifecycleService {
99     private static final String TAG = "CameraXService";
100     private static final int NOTIFICATION_ID = 1;
101     private static final String CHANNEL_ID_SERVICE_INFO = "channel_service_info";
102     private static final int FRAME_COUNT_TO_UPDATE_ANALYSIS_INFO = 60;
103 
104     // Actions
105     public static final String ACTION_BIND_USE_CASES =
106             "androidx.camera.integration.core.intent.action.BIND_USE_CASES";
107     public static final String ACTION_TAKE_PICTURE =
108             "androidx.camera.integration.core.intent.action.TAKE_PICTURE";
109     public static final String ACTION_START_RECORDING =
110             "androidx.camera.integration.core.intent.action.START_RECORDING";
111     public static final String ACTION_STOP_RECORDING =
112             "androidx.camera.integration.core.intent.action.STOP_RECORDING";
113     public static final String ACTION_STOP_SERVICE =
114             "androidx.camera.integration.core.intent.action.STOP_SERVICE";
115 
116     // Extras
117     public static final String EXTRA_VIDEO_CAPTURE_ENABLED = "EXTRA_VIDEO_CAPTURE_ENABLED";
118     public static final String EXTRA_IMAGE_CAPTURE_ENABLED = "EXTRA_IMAGE_CAPTURE_ENABLED";
119     public static final String EXTRA_IMAGE_ANALYSIS_ENABLED = "EXTRA_IMAGE_ANALYSIS_ENABLED";
120 
121     private final IBinder mBinder = new CameraXServiceBinder();
122     private final AtomicInteger mAnalysisFrameCount = new AtomicInteger(0);
123 
124     ////////////////////////////////////////////////////////////////////////////////////////////////
125     //                          Members only accessed on main thread                              //
126     ////////////////////////////////////////////////////////////////////////////////////////////////
127     private final Map<Class<?>, UseCase> mBoundUseCases = new HashMap<>();
128     private @Nullable Recording mActiveRecording;
129     private NotificationCompat.@Nullable Builder mNotificationBuilder;
130     //--------------------------------------------------------------------------------------------//
131 
132     ////////////////////////////////////////////////////////////////////////////////////////////////
133     //                                   Members for testing                                      //
134     ////////////////////////////////////////////////////////////////////////////////////////////////
135     private final Set<Uri> mSavedMediaUri = new HashSet<>();
136 
137     private @Nullable Consumer<Collection<UseCase>> mOnUseCaseBoundCallback;
138     private @Nullable CountDownLatch mAnalysisFrameLatch;
139     private @Nullable CountDownLatch mTakePictureLatch;
140     private @Nullable CountDownLatch mRecordVideoLatch;
141     //--------------------------------------------------------------------------------------------//
142 
143     @Override
onCreate()144     public void onCreate() {
145         super.onCreate();
146         makeForeground();
147     }
148 
149     @Override
onBind(@onNull Intent intent)150     public @Nullable IBinder onBind(@NonNull Intent intent) {
151         super.onBind(intent);
152         return mBinder;
153     }
154 
155     @Override
onStartCommand(@ullable Intent intent, int flags, int startId)156     public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
157         if (intent != null) {
158             String action = intent.getAction();
159             Log.d(TAG, "onStartCommand: action = " + action + ", extras = " + intent.getExtras());
160             if (ACTION_BIND_USE_CASES.equals(action)) {
161                 bindToLifecycle(intent);
162             } else if (ACTION_TAKE_PICTURE.equals(action)) {
163                 takePicture();
164             } else if (ACTION_START_RECORDING.equals(action)) {
165                 startRecording();
166             } else if (ACTION_STOP_RECORDING.equals(action)) {
167                 stopRecording();
168             } else if (ACTION_STOP_SERVICE.equals(action)) {
169                 stopForeground(true);
170                 stopSelf();
171             }
172         }
173         return super.onStartCommand(intent, flags, startId);
174     }
175 
makeForeground()176     private void makeForeground() {
177         createNotificationChannel();
178         mNotificationBuilder = new NotificationCompat.Builder(this,
179                 CHANNEL_ID_SERVICE_INFO)
180                 .setSmallIcon(android.R.drawable.ic_menu_camera)
181                 .setStyle(new NotificationCompat.DecoratedCustomViewStyle())
182                 .setCustomContentView(getNotificationView());
183         startForeground(NOTIFICATION_ID, mNotificationBuilder.build());
184     }
185 
bindToLifecycle(@onNull Intent intent)186     private void bindToLifecycle(@NonNull Intent intent) {
187         ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
188                 ProcessCameraProvider.getInstance(this);
189         ProcessCameraProvider cameraProvider;
190         try {
191             cameraProvider = cameraProviderFuture.get();
192         } catch (ExecutionException | InterruptedException e) {
193             throw new IllegalStateException(e);
194         }
195         cameraProvider.unbindAll();
196         mBoundUseCases.clear();
197         UseCaseGroup useCaseGroup = resolveUseCaseGroup(intent);
198         List<UseCase> boundUseCases = Collections.emptyList();
199         if (useCaseGroup != null) {
200             try {
201                 cameraProvider.bindToLifecycle(this, DEFAULT_BACK_CAMERA, useCaseGroup);
202                 boundUseCases = useCaseGroup.getUseCases();
203             } catch (IllegalArgumentException e) {
204                 String msg = "Failed to bind by " + e;
205                 Log.w(TAG, msg, e);
206                 Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
207             }
208         }
209         onUseCaseBound(boundUseCases);
210     }
211 
resolveUseCaseGroup(@onNull Intent intent)212     private @Nullable UseCaseGroup resolveUseCaseGroup(@NonNull Intent intent) {
213         boolean hasUseCase = false;
214         UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder();
215 
216         if (intent.getBooleanExtra(EXTRA_VIDEO_CAPTURE_ENABLED, false)) {
217             Recorder recorder = new Recorder.Builder().build();
218             VideoCapture<?> videoCapture = new VideoCapture.Builder<>(recorder).build();
219             useCaseGroupBuilder.addUseCase(videoCapture);
220             hasUseCase = true;
221         }
222         if (intent.getBooleanExtra(EXTRA_IMAGE_CAPTURE_ENABLED, false)) {
223             ImageCapture imageCapture = new ImageCapture.Builder().build();
224             useCaseGroupBuilder.addUseCase(imageCapture);
225             hasUseCase = true;
226         }
227         if (intent.getBooleanExtra(EXTRA_IMAGE_ANALYSIS_ENABLED, false)) {
228             ImageAnalysis imageAnalysis = new ImageAnalysis.Builder().build();
229             imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(this), mAnalyzer);
230             useCaseGroupBuilder.addUseCase(imageAnalysis);
231             hasUseCase = true;
232         }
233 
234         return hasUseCase ? useCaseGroupBuilder.build() : null;
235     }
236 
onUseCaseBound(@onNull List<UseCase> boundUseCases)237     private void onUseCaseBound(@NonNull List<UseCase> boundUseCases) {
238         Log.d(TAG, "Bound UseCases: " + boundUseCases);
239         for (UseCase boundUseCase : boundUseCases) {
240             mBoundUseCases.put(boundUseCase.getClass(), boundUseCase);
241         }
242         if (mOnUseCaseBoundCallback != null) {
243             mOnUseCaseBoundCallback.accept(boundUseCases);
244         }
245         mAnalysisFrameCount.set(0);
246         updateNotification();
247         Toast.makeText(CameraXService.this, getHumanReadableName(boundUseCases) + " is bound",
248                 Toast.LENGTH_SHORT).show();
249     }
250 
createNotificationChannel()251     private void createNotificationChannel() {
252         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
253             NotificationChannel serviceChannel = Api26Impl.newNotificationChannel(
254                     CHANNEL_ID_SERVICE_INFO,
255                     getString(R.string.camerax_service),
256                     NotificationManager.IMPORTANCE_DEFAULT
257             );
258             Api26Impl.createNotificationChannel(getNotificationManager(), serviceChannel);
259         }
260     }
261 
getNotificationManager()262     private @NonNull NotificationManager getNotificationManager() {
263         return checkNotNull(ContextCompat.getSystemService(this, NotificationManager.class));
264     }
265 
updateNotification()266     private void updateNotification() {
267         NotificationCompat.Builder builder = checkNotNull(mNotificationBuilder);
268         builder.setCustomContentView(getNotificationView());
269         getNotificationManager().notify(NOTIFICATION_ID, builder.build());
270     }
271 
getNotificationView()272     private @NonNull RemoteViews getNotificationView() {
273         RemoteViews notificationView = new RemoteViews(getPackageName(),
274                 R.layout.notification_service_collapsed);
275 
276         // Update VideoCapture view
277         if (getVideoCapture() != null) {
278             PendingIntent recordingIntent;
279             int recordingIconResId;
280             int recordingStateVisibility;
281             RemoteViews videoView = new RemoteViews(getPackageName(),
282                     R.layout.notification_video_widget);
283             if (mActiveRecording == null) {
284                 int flags = PendingIntent.FLAG_UPDATE_CURRENT;
285                 if (Build.VERSION.SDK_INT >= 23) {
286                     flags |= PendingIntent.FLAG_IMMUTABLE;
287                 }
288                 recordingIntent = PendingIntent.getService(this, 0,
289                         new Intent(ACTION_START_RECORDING), flags);
290                 recordingIconResId = android.R.drawable.ic_media_play;
291                 recordingStateVisibility = View.GONE;
292             } else {
293                 recordingIntent = PendingIntent.getService(this, 0,
294                         new Intent(ACTION_STOP_RECORDING),
295                         PendingIntent.FLAG_IMMUTABLE);
296                 recordingIconResId = R.drawable.ic_media_stop;
297                 recordingStateVisibility = View.VISIBLE;
298             }
299             videoView.setOnClickPendingIntent(R.id.control, recordingIntent);
300             videoView.setImageViewResource(R.id.control, recordingIconResId);
301             videoView.setViewVisibility(R.id.state, recordingStateVisibility);
302             notificationView.addView(R.id.video_container, videoView);
303         }
304 
305         // Update ImageCapture view
306         if (getImageCapture() != null) {
307             RemoteViews imageView = new RemoteViews(getPackageName(),
308                     R.layout.notification_image_widget);
309             PendingIntent takePictureIntent = PendingIntent.getService(this, 0,
310                     new Intent(ACTION_TAKE_PICTURE),
311                     PendingIntent.FLAG_IMMUTABLE);
312             imageView.setOnClickPendingIntent(R.id.picture, takePictureIntent);
313             notificationView.addView(R.id.image_container, imageView);
314         }
315 
316         // Update ImageAnalysis view
317         if (getImageAnalysis() != null) {
318             RemoteViews analysisView = new RemoteViews(getPackageName(),
319                     R.layout.notification_analysis_widget);
320             String analysisMsg = "Analysis:" + mAnalysisFrameCount.get();
321             analysisView.setTextViewText(R.id.text_view, analysisMsg);
322             notificationView.addView(R.id.analysis_container, analysisView);
323         }
324 
325         // Update exit button
326         notificationView.setOnClickPendingIntent(R.id.exit,
327                 PendingIntent.getService(this, 0, new Intent(ACTION_STOP_SERVICE),
328                         PendingIntent.FLAG_IMMUTABLE));
329 
330         return notificationView;
331     }
332 
getImageCapture()333     private @Nullable ImageCapture getImageCapture() {
334         return (ImageCapture) mBoundUseCases.get(ImageCapture.class);
335     }
336 
337     @SuppressWarnings("unchecked")
getVideoCapture()338     private @Nullable VideoCapture<Recorder> getVideoCapture() {
339         return (VideoCapture<Recorder>) mBoundUseCases.get(VideoCapture.class);
340     }
341 
getImageAnalysis()342     private @Nullable ImageAnalysis getImageAnalysis() {
343         return (ImageAnalysis) mBoundUseCases.get(ImageAnalysis.class);
344     }
345 
takePicture()346     private void takePicture() {
347         ImageCapture imageCapture = getImageCapture();
348         if (imageCapture == null) {
349             Log.w(TAG, "ImageCapture is not bound.");
350             return;
351         }
352         createDefaultPictureFolderIfNotExist();
353         Format formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US);
354         String fileName = "ServiceTestApp-" + formatter.format(Calendar.getInstance().getTime())
355                 + ".jpg";
356         ContentValues contentValues = new ContentValues();
357         contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
358         contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg");
359         ImageCapture.OutputFileOptions outputFileOptions =
360                 new ImageCapture.OutputFileOptions.Builder(
361                         getContentResolver(),
362                         MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
363                         contentValues).build();
364         long startTimeMs = SystemClock.elapsedRealtime();
365         imageCapture.takePicture(outputFileOptions,
366                 ContextCompat.getMainExecutor(this),
367                 new ImageCapture.OnImageSavedCallback() {
368                     @Override
369                     public void onImageSaved(
370                             ImageCapture.@NonNull OutputFileResults outputFileResults) {
371                         long durationMs = SystemClock.elapsedRealtime() - startTimeMs;
372                         String msg = "Saved image " + outputFileResults.getSavedUri()
373                                 + "  (" + durationMs + " ms)";
374                         Log.d(TAG, msg);
375                         Toast.makeText(CameraXService.this, msg, Toast.LENGTH_LONG).show();
376                         mSavedMediaUri.add(outputFileResults.getSavedUri());
377                         if (mTakePictureLatch != null) {
378                             mTakePictureLatch.countDown();
379                         }
380                     }
381 
382                     @Override
383                     public void onError(@NonNull ImageCaptureException exception) {
384                         String msg = "Failed to save image by " + exception.getImageCaptureError();
385                         Log.e(TAG, msg, exception);
386                         Toast.makeText(CameraXService.this, msg, Toast.LENGTH_SHORT).show();
387                     }
388                 });
389     }
390 
createDefaultPictureFolderIfNotExist()391     private void createDefaultPictureFolderIfNotExist() {
392         File pictureFolder = Environment.getExternalStoragePublicDirectory(
393                 Environment.DIRECTORY_PICTURES);
394         if (!createFolder(pictureFolder)) {
395             Log.e(TAG, "Failed to create directory: " + pictureFolder);
396         }
397     }
398 
startRecording()399     private void startRecording() {
400         VideoCapture<Recorder> videoCapture = getVideoCapture();
401         if (videoCapture == null) {
402             Log.w(TAG, "VideoCapture is not bound.");
403             return;
404         }
405 
406         createDefaultVideoFolderIfNotExist();
407         if (mActiveRecording == null) {
408             PendingRecording pendingRecording;
409             String fileName = "video_" + System.currentTimeMillis();
410             String extension = "mp4";
411             if (canDeviceWriteToMediaStore()) {
412                 // Use MediaStoreOutputOptions for public share media storage.
413                 pendingRecording = getVideoCapture().getOutput().prepareRecording(
414                         this,
415                         generateVideoMediaStoreOptions(getContentResolver(), fileName));
416             } else {
417                 // Use FileOutputOption for devices in MediaStoreVideoCannotWrite Quirk.
418                 pendingRecording = getVideoCapture().getOutput().prepareRecording(
419                         this, generateVideoFileOutputOptions(fileName, extension));
420             }
421             //noinspection MissingPermission
422             mActiveRecording = pendingRecording
423                     .withAudioEnabled()
424                     .start(ContextCompat.getMainExecutor(this), mRecordingListener);
425             updateNotification();
426         } else {
427             Log.e(TAG, "It should stop the active recording before start a new one.");
428         }
429     }
430 
stopRecording()431     private void stopRecording() {
432         if (mActiveRecording != null) {
433             mActiveRecording.stop();
434             mActiveRecording = null;
435         }
436     }
437 
createDefaultVideoFolderIfNotExist()438     private void createDefaultVideoFolderIfNotExist() {
439         String videoFilePath = getAbsolutePathFromUri(getContentResolver(),
440                 MediaStore.Video.Media.EXTERNAL_CONTENT_URI);
441         if (videoFilePath == null || !createParentFolder(videoFilePath)) {
442             Log.e(TAG, "Failed to create parent directory for: " + videoFilePath);
443         }
444     }
445 
getHumanReadableName(@onNull List<UseCase> useCases)446     private static @NonNull String getHumanReadableName(@NonNull List<UseCase> useCases) {
447         List<String> useCaseNames = new ArrayList<>();
448         for (UseCase useCase : useCases) {
449             useCaseNames.add(useCase.getClass().getSimpleName());
450         }
451         return useCaseNames.size() > 0 ? String.join(" | ", useCaseNames) : "No UseCase";
452     }
453 
454     private final ImageAnalysis.Analyzer mAnalyzer = image -> {
455         if (mAnalysisFrameCount.getAndIncrement() % FRAME_COUNT_TO_UPDATE_ANALYSIS_INFO == 0) {
456             updateNotification();
457         }
458         if (mAnalysisFrameLatch != null) {
459             mAnalysisFrameLatch.countDown();
460         }
461         image.close();
462     };
463 
464     private final Consumer<VideoRecordEvent> mRecordingListener = event -> {
465         if (event instanceof VideoRecordEvent.Finalize) {
466             VideoRecordEvent.Finalize finalize = (VideoRecordEvent.Finalize) event;
467 
468             switch (finalize.getError()) {
469                 case ERROR_NONE:
470                 case ERROR_FILE_SIZE_LIMIT_REACHED:
471                 case ERROR_DURATION_LIMIT_REACHED:
472                 case ERROR_INSUFFICIENT_STORAGE:
473                 case ERROR_SOURCE_INACTIVE:
474                     Uri uri = finalize.getOutputResults().getOutputUri();
475                     OutputOptions outputOptions = finalize.getOutputOptions();
476                     String msg;
477                     String videoFilePath;
478                     if (outputOptions instanceof MediaStoreOutputOptions) {
479                         msg = "Saved video " + uri;
480                         videoFilePath = getAbsolutePathFromUri(
481                                 getApplicationContext().getContentResolver(),
482                                 uri
483                         );
484                     } else if (outputOptions instanceof FileOutputOptions) {
485                         videoFilePath = ((FileOutputOptions) outputOptions).getFile().getPath();
486                         MediaScannerConnection.scanFile(this,
487                                 new String[]{videoFilePath}, null,
488                                 (path, uri1) -> Log.i(TAG, "Scanned " + path + " -> uri= " + uri1));
489                         msg = "Saved video " + videoFilePath;
490                     } else {
491                         throw new AssertionError("Unknown or unsupported OutputOptions type: "
492                                 + outputOptions.getClass().getSimpleName());
493                     }
494                     // The video file path is used in tracing e2e test log. Don't remove it.
495                     Log.d(TAG, "Saved video file: " + videoFilePath);
496 
497                     if (finalize.getError() != ERROR_NONE) {
498                         msg += " with code (" + finalize.getError() + ")";
499                     }
500                     Log.d(TAG, msg, finalize.getCause());
501                     Toast.makeText(CameraXService.this, msg, Toast.LENGTH_LONG).show();
502 
503                     mSavedMediaUri.add(uri);
504                     if (mRecordVideoLatch != null) {
505                         mRecordVideoLatch.countDown();
506                     }
507                     break;
508                 default:
509                     String errMsg = "Video capture failed by (" + finalize.getError() + "): "
510                             + finalize.getCause();
511                     Log.e(TAG, errMsg, finalize.getCause());
512                     Toast.makeText(CameraXService.this, errMsg, Toast.LENGTH_LONG).show();
513             }
514             mActiveRecording = null;
515             updateNotification();
516         }
517     };
518 
519     @RequiresApi(26)
520     static class Api26Impl {
521 
Api26Impl()522         private Api26Impl() {
523         }
524 
525         /** @noinspection SameParameterValue */
newNotificationChannel(@onNull String id, @NonNull CharSequence name, int importance)526         static @NonNull NotificationChannel newNotificationChannel(@NonNull String id,
527                 @NonNull CharSequence name, int importance) {
528             return new NotificationChannel(id, name, importance);
529         }
530 
createNotificationChannel(@onNull NotificationManager manager, @NonNull NotificationChannel channel)531         static void createNotificationChannel(@NonNull NotificationManager manager,
532                 @NonNull NotificationChannel channel) {
533             manager.createNotificationChannel(channel);
534         }
535     }
536 
537     @VisibleForTesting
setOnUseCaseBoundCallback(@onNull Consumer<Collection<UseCase>> callback)538     void setOnUseCaseBoundCallback(@NonNull Consumer<Collection<UseCase>> callback) {
539         mOnUseCaseBoundCallback = callback;
540     }
541 
542     @VisibleForTesting
acquireAnalysisFrameCountDownLatch()543     @NonNull CountDownLatch acquireAnalysisFrameCountDownLatch() {
544         mAnalysisFrameLatch = new CountDownLatch(3);
545         return mAnalysisFrameLatch;
546     }
547 
548     @VisibleForTesting
acquireTakePictureCountDownLatch()549     @NonNull CountDownLatch acquireTakePictureCountDownLatch() {
550         mTakePictureLatch = new CountDownLatch(1);
551         return mTakePictureLatch;
552     }
553 
554     @VisibleForTesting
acquireRecordVideoCountDownLatch()555     @NonNull CountDownLatch acquireRecordVideoCountDownLatch() {
556         mRecordVideoLatch = new CountDownLatch(1);
557         return mRecordVideoLatch;
558     }
559 
560     @VisibleForTesting
deleteSavedMediaFiles()561     void deleteSavedMediaFiles() {
562         deleteUriSet(mSavedMediaUri);
563     }
564 
deleteUriSet(@onNull Set<Uri> uriSet)565     private void deleteUriSet(@NonNull Set<Uri> uriSet) {
566         for (Uri uri : uriSet) {
567             try {
568                 getContentResolver().delete(uri, null, null);
569             } catch (RuntimeException e) {
570                 Log.w(TAG, "Unable to delete uri: " + uri, e);
571             }
572         }
573     }
574 
575     class CameraXServiceBinder extends Binder {
getService()576         @NonNull CameraXService getService() {
577             return CameraXService.this;
578         }
579     }
580 }
581