diff --git a/AGENTS.md b/AGENTS.md index a528bda08..d9a8e152d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,12 +141,14 @@ extension bind `UnsupportedOperationException`s, gallery NPEs. ### Legacy -The pre-migration code has no DI, no ViewModels, no coroutines in the camera path (raw -`thread {}`, `Executors`, `Handler`). -`CamConfig` holds `private val mActivity: MainActivity` and some of its properties read the View -tree directly (e.g. `requireLocation`'s getter returns -`mActivity.settingsDialog.locToggle.isChecked`). This coupling is the thing the migration exists to -undo — do not add to it. +The pre-migration code has no ViewModels and no coroutines in the camera path (raw `thread {}`, +`Executors`, `Handler`). `MainActivity` still owns the viewfinder's View tree, and the classes +around it — `SettingsDialog`, `ImageCapturer`, `VideoCapturer`, `QRAnalyzer`, the custom Views — +hold a `MainActivity` and reach into it. Reading state back out of that tree is the coupling the +migration exists to undo (`CamConfig` used to answer `requireLocation` with +`settingsDialog.locToggle.isChecked`); `ViewfinderController` now holds the state and pushes it out +through `ViewfinderChrome`/`ViewfinderEffects`. Do not add a new read of the View tree from below +the UI. ### Target @@ -252,11 +254,11 @@ not otherwise changing — whitespace churn buries the diff and makes the migrat - **No expression-body functions.** Always a block body with an explicit return type: ```kotlin // WRONG - fun currentMode() = camConfig.currentMode + fun currentMode() = viewfinder.currentMode // CORRECT fun currentMode(): CameraMode { - return camConfig.currentMode + return viewfinder.currentMode } ``` Return type is omitted for functions returning `Unit`; write `fun bind() {`, not @@ -446,5 +448,6 @@ migrating the UI is exactly when they stop being reachable, and left behind they - **Never add a commit co-author unless the user explicitly asks.** - Commit messages: imperative mood, describing the behavior change rather than the mechanism — match the existing log ("Don't initialize the camera while its permission is not granted"). -- Test-facing seams in `CamConfig` (`mPlayer`, `photoQuality`, `camera`, `switchMode`) are written - to by the instrumented suite. They stay writable until the screen that owns them is migrated. +- Test-facing seams in `ViewfinderController` (`mPlayer`, `photoQuality`, `switchMode`) and + `CameraSession` (`camera`) are written to by the instrumented suite. They stay writable until the + screen that owns them is migrated. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4539d89b2..1c0c82f9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,9 +182,11 @@ dependencies { testImplementation(libs.junit4) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core.ktx) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.ext.junit.ktx) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 8b395637b..c87aba8a4 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -69,41 +69,24 @@ - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - @@ -112,68 +95,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -264,41 +185,38 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -352,36 +270,36 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -413,11 +331,11 @@ - - - - - + + + + + @@ -454,82 +372,82 @@ - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -605,145 +523,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - + @@ -799,14 +585,14 @@ - - - - + + + + - + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index c25cdc152..30fbea964 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -4,22 +4,21 @@ ComplexCondition:InAppGallery.kt:InAppGallery$width != null && height != null && width > 0 && height > 0 ComplexCondition:ZoomableImageView.kt:ZoomableImageView$oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0 - CyclomaticComplexMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + CyclomaticComplexMethod:CameraSession.kt:CameraSessionImpl$@SuppressLint("RestrictedApi") override fun bind: BindOutcome CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$override fun onCreate CyclomaticComplexMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails CyclomaticComplexMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate - CyclomaticComplexMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange CyclomaticComplexMethod:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged CyclomaticComplexMethod:VideoCapturer.kt:VideoCapturer$fun startRecording EmptyCatchBlock:QRAnalyzer.kt:QRAnalyzer${ } EmptyFunctionBlock:ActivityLifeCycleHelper.kt:ActivityLifeCycleHelper${} EmptyFunctionBlock:App.kt:App.<no name provided>${} - EmptyFunctionBlock:CamConfig.kt:CamConfig.<no name provided>${} EmptyFunctionBlock:ImageCapturer.kt:ImageCapturer.<no name provided>${} - EmptyFunctionBlock:MainActivity.kt:MainActivity${} EmptyFunctionBlock:MainActivity.kt:MainActivity.<no name provided>${} EmptyFunctionBlock:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener${} EmptyFunctionBlock:SettingsDialog.kt:SettingsDialog.<no name provided>${} + EmptyFunctionBlock:ViewfinderEffectHandler.kt:ViewfinderEffectHandler.<no name provided>${} + EmptyFunctionBlock:ViewfinderGestureHandler.kt:ViewfinderGestureHandler${} EmptyFunctionBlock:ZoomableImageView.kt:ZoomableImageView.<no name provided>${} HasPlatformType:ImageSaver.kt:ImageSaver$val contentResolver = appContext.contentResolver HasPlatformType:ImageSaver.kt:ImageSaver$val mainThreadExecutor = appContext.mainExecutor @@ -29,18 +28,16 @@ HasPlatformType:MainActivity.kt:MainActivity$val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() ImplicitDefaultLocale:QRAnalyzer.kt:QRAnalyzer$"%.02f".format(fps) ImplicitDefaultLocale:ZoomBar.kt:ZoomBar$String.format("%.1fx", zoomRatio) - InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is IllegalArgumentException - InstanceOfCheckForException:CamConfig.kt:CamConfig$exception !is UnsupportedOperationException - InstanceOfCheckForException:CamConfig.kt:CamConfig$exception is IllegalArgumentException - LargeClass:CamConfig.kt:CamConfig - LargeClass:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + InstanceOfCheckForException:CameraSession.kt:CameraSessionImpl$exception !is IllegalArgumentException + InstanceOfCheckForException:CameraSession.kt:CameraSessionImpl$exception !is UnsupportedOperationException + InstanceOfCheckForException:CameraSession.kt:CameraSessionImpl$exception is IllegalArgumentException + LargeClass:MainActivity.kt:MainActivity : AppCompatActivity LongMethod:BlurBitmap.kt:BlurBitmap$operator fun get: Bitmap - LongMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + LongMethod:CameraSession.kt:CameraSessionImpl$@SuppressLint("RestrictedApi") override fun bind: BindOutcome LongMethod:GallerySliderAdapter.kt:GallerySliderAdapter$override fun onBindViewHolder LongMethod:InAppGallery.kt:InAppGallery$override fun onCreate LongMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails LongMethod:MainActivity.kt:MainActivity$@SuppressLint("ClickableViewAccessibility") override fun onCreate - LongMethod:MainActivity.kt:MainActivity$fun onDeviceAngleChange LongMethod:MainActivity.kt:MainActivity$fun onScanResultSuccess LongMethod:MoreSettings.kt:MoreSettings$override fun onCreate LongMethod:SettingsDialog.kt:SettingsDialog$fun selfIllumination @@ -75,19 +72,15 @@ MagicNumber:MainActivity.kt:MainActivity$16 MagicNumber:MainActivity.kt:MainActivity$180 MagicNumber:MainActivity.kt:MainActivity$270 - MagicNumber:MainActivity.kt:MainActivity$270f MagicNumber:MainActivity.kt:MainActivity$3 MagicNumber:MainActivity.kt:MainActivity$300 - MagicNumber:MainActivity.kt:MainActivity$360f MagicNumber:MainActivity.kt:MainActivity$4 MagicNumber:MainActivity.kt:MainActivity$400 - MagicNumber:MainActivity.kt:MainActivity$5 MagicNumber:MainActivity.kt:MainActivity$500 MagicNumber:MainActivity.kt:MainActivity$7 MagicNumber:MainActivity.kt:MainActivity$8 MagicNumber:MainActivity.kt:MainActivity$800 MagicNumber:MainActivity.kt:MainActivity$90 - MagicNumber:MainActivity.kt:MainActivity$90f MagicNumber:PackageManagerUtils.kt:33 MagicNumber:PreviewView.kt:16 MagicNumber:PreviewView.kt:180 @@ -104,13 +97,17 @@ MagicNumber:VideoCapturer.kt:VideoCapturer$1_000_000_000 MagicNumber:VideoCapturer.kt:VideoCapturer$300 MagicNumber:VideoPlayer.kt:VideoPlayer$300 + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$270f + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$360f + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$4 + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$400 + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$5 + MagicNumber:ViewfinderOrientationHandler.kt:ViewfinderOrientationHandler$90f MagicNumber:ZoomBar.kt:ZoomBar$100 MagicNumber:ZoomBar.kt:ZoomBar$100f MagicNumber:ZoomBar.kt:ZoomBar$300 MagicNumber:ZoomBar.kt:ZoomBar$90f MatchingDeclarationName:ImageDecoderUtils.kt:ImageResizer : OnHeaderDecodedListener - MaxLineLength:CamConfig.kt:CamConfig$resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) - MaxLineLength:MainActivity.kt:MainActivity$if (cameraPermissionDialog != null && cameraPermissionDialog!!.isShowing) cameraPermissionDialog!!.cancel() MaxLineLength:SettingsDialog.kt:SettingsDialog$mActivity.showMessage("Enabling audio while recording is not currently supported when it was disabled at the start") NestedBlockDepth:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails NewLineAtEndOfFile:GSlideTransformer.kt:app.grapheneos.camera.GSlideTransformer.kt @@ -119,35 +116,40 @@ NewLineAtEndOfFile:VideoCaptureActivity.kt:app.grapheneos.camera.ui.activities.VideoCaptureActivity.kt NewLineAtEndOfFile:VideoOnlyActivity.kt:app.grapheneos.camera.ui.activities.VideoOnlyActivity.kt NoNameShadowing:MoreSettings.kt:MoreSettings${ if (it.toString().contains(CapturedItems.SAF_TREE_SEPARATOR)) { null } else { it } } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(enableCameraSounds = csSwitch.isChecked) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(enableZsl = !settings.enableZsl) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(gyroscopeSuggestions = gSwitch.isChecked) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(removeExifAfterCapture = exifToggle.isChecked) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(saveImageAsPreviewed = sIAPToggle.isChecked) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(saveVideoAsPreviewed = sVAPToggle.isChecked) } + NoNameShadowing:MoreSettings.kt:MoreSettings${ it.copy(selectHighestResolution = !settings.selectHighestResolution) } NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$xAngle NoNameShadowing:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$zAngle PrintStackTrace:InAppGallery.kt:InAppGallery$e - PrintStackTrace:MainActivity.kt:MainActivity$exception PrintStackTrace:VideoCapturer.kt:VideoCapturer$e PrintStackTrace:VideoCapturer.kt:e + PrintStackTrace:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$exception ReturnCount:App.kt:App$fun getLocation: Location? ReturnCount:App.kt:App$fun isAnyLocationProvideActive: Boolean + ReturnCount:BarcodeFormats.kt:BarcodeFormats$fun setEnabled: Boolean ReturnCount:BottomTabLayout.kt:BottomTabLayout$private fun settleDuration: Long - ReturnCount:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera - ReturnCount:CamConfig.kt:CamConfig$@androidx.annotation.OptIn(ExperimentalCamera2Interop::class) private fun canVerifyFeatureCombinations: Boolean - ReturnCount:CamConfig.kt:CamConfig$private fun isExtensionUsable: Boolean - ReturnCount:CamConfig.kt:CamConfig$private fun isLensFacingSupported: Boolean - ReturnCount:CamConfig.kt:CamConfig$private fun loadTabs - ReturnCount:CamConfig.kt:CamConfig$private fun videoQualityAsGroupableFeature: GroupableFeature? + ReturnCount:ViewfinderController.kt:ViewfinderController$fun startCamera + ReturnCount:CameraSession.kt:CameraSessionImpl$@SuppressLint("RestrictedApi") override fun bind: BindOutcome + ReturnCount:CameraSession.kt:CameraSessionImpl$override fun isLensFacingSupported: Boolean + ReturnCount:CameraSession.kt:CameraSessionImpl$override fun probeUnknownExtensions + ReturnCount:CameraSession.kt:CameraSessionImpl$private fun isExtensionUsable: Boolean ReturnCount:CapturedItems.kt:CapturedItems$fun parseCapturedItem: CapturedItem? ReturnCount:ImageCapturer.kt:ImageCapturer$@SuppressLint("RestrictedApi") fun takePicture ReturnCount:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails ReturnCount:MainActivity.kt:MainActivity$fun prefetchLastFrame - ReturnCount:MainActivity.kt:MainActivity$override fun onTouch: Boolean - ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeLeft - ReturnCount:MainActivity.kt:MainActivity$private fun onSwipeRight ReturnCount:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier.NotifierSensorEventListener$override fun onSensorChanged ReturnCount:SettingsDialog.kt:SettingsDialog$private fun updatePanelRegion: Boolean ReturnCount:Utils.kt:fun storageLocationToUiString: String ReturnCount:VideoCapturer.kt:VideoCapturer$fun startRecording ReturnCount:VideoCapturer.kt:VideoCapturer$private fun createRecordingContext: RecordingContext? - SwallowedException:CamConfig.kt:CamConfig$e: ExecutionException - SwallowedException:CamConfig.kt:CamConfig$e: IllegalArgumentException + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$override fun onTouch: Boolean + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeLeft + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeRight SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception SwallowedException:CapturedItems.kt:e: ActivityNotFoundException SwallowedException:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception @@ -157,41 +159,32 @@ SwallowedException:VideoCapturer.kt:VideoCapturer$e: Exception SwallowedException:VideoCapturer.kt:VideoCapturer$exception: Exception ThrowsCount:ImageSaver.kt:ImageSaver$@Throws(ImageSaverException::class) private fun saveImageInner - TooGenericExceptionCaught:CamConfig.kt:CamConfig$e: Exception - TooGenericExceptionCaught:CamConfig.kt:CamConfig$exception: RuntimeException + TooGenericExceptionCaught:CameraSession.kt:CameraSessionImpl$exception: RuntimeException TooGenericExceptionCaught:CaptureActivity.kt:CaptureActivity$e: Exception TooGenericExceptionCaught:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$deleteException: Exception TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$e: Exception TooGenericExceptionCaught:InAppGallery.kt:InAppGallery$e: Exception TooGenericExceptionCaught:MainActivity.kt:MainActivity$e: Exception - TooGenericExceptionCaught:MainActivity.kt:MainActivity$exception: Exception TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog$exception: Exception TooGenericExceptionCaught:SettingsDialog.kt:SettingsDialog.<no name provided>$exception: Exception TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$e: Exception TooGenericExceptionCaught:VideoCapturer.kt:VideoCapturer$exception: Exception TooGenericExceptionCaught:VideoCapturer.kt:e: Exception TooGenericExceptionCaught:VideoPlayer.kt:VideoPlayer$e: Exception - TooManyFunctions:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + TooGenericExceptionCaught:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$exception: Exception TopLevelPropertyNaming:ImageCapturer.kt:private const val imageFileFormat = ".jpg" UnsafeCallOnNullableType:BlurBitmap.kt:BlurBitmap$sentBitmap.config!! UnsafeCallOnNullableType:BottomTabLayout.kt:BottomTabLayout$getTabAt(it)!! - UnsafeCallOnNullableType:CamConfig.kt:CamConfig$camera!! - UnsafeCallOnNullableType:CamConfig.kt:CamConfig$cameraProvider!! - UnsafeCallOnNullableType:CamConfig.kt:CamConfig$imageCapture!! - UnsafeCallOnNullableType:CamConfig.kt:CamConfig$videoCapture!! UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! - UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! + UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$session.imageCapture!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$contentResolver.openAssetFileDescriptor(uri, "w")!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$obtainOutputUri()!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$origJpegBytes!! UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME)!! UnsafeCallOnNullableType:InAppGallery.kt:InAppGallery$eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!! - UnsafeCallOnNullableType:MainActivity.kt:MainActivity$camConfig.camera!! UnsafeCallOnNullableType:MainActivity.kt:MainActivity$cameraPermissionDialog!! - UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data.encodedPath!! - UnsafeCallOnNullableType:MainActivity.kt:MainActivity$data?.encodedPath!! UnsafeCallOnNullableType:QrTile.kt:QrTile$getSystemService<KeyguardManager>()!! UnsafeCallOnNullableType:SensorOrientationChangeNotifier.kt:SensorOrientationChangeNotifier$wr.get()!! UnsafeCallOnNullableType:SettingsDialog.kt:SettingsDialog$Looper.myLooper()!! diff --git a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 6ae24426a..003b29504 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt @@ -47,7 +47,7 @@ class BottomTabLayoutRegressionTest { var travel = 0 scenario.onActivity { activity -> - activity.camConfig.switchMode(activity.tabLayout.getTabAt(0)!!.tag as CameraMode) + activity.viewfinder.switchMode(activity.tabLayout.getTabAt(0)!!.tag as CameraMode) } waitUntil(scenario, "the strip is centred on the first tab") { it.tabLayout.scrollX == centreOf(it.tabLayout, 0) @@ -127,7 +127,7 @@ class BottomTabLayoutRegressionTest { dragStrip(scenario, scrollPx = travel) waitUntil(scenario, "the camera switched to $targetMode") { - it.camConfig.currentMode == targetMode + it.viewfinder.currentMode == targetMode } waitUntil(scenario, "the strip settled on tab $target") { it.tabLayout.scrollX == centreOf(it.tabLayout, target) @@ -176,7 +176,7 @@ class BottomTabLayoutRegressionTest { assertNotEquals(selectedAtStart, target) waitUntil(scenario, "the camera switched to the tab the drag ended on") { - it.camConfig.currentMode == it.tabLayout.getTabAt(target)!!.tag + it.viewfinder.currentMode == it.tabLayout.getTabAt(target)!!.tag } } } @@ -199,7 +199,7 @@ class BottomTabLayoutRegressionTest { var travel = 0 scenario.onActivity { activity -> val tabs = activity.tabLayout - startMode = activity.camConfig.currentMode + startMode = activity.viewfinder.currentMode target = nextTo(tabs.selectedTabPosition, tabs.tabCount) // Two thirds of the way over: enough to commit the mode, and far enough short of // the centre that the strip has a settle worth measuring left to run. @@ -215,12 +215,12 @@ class BottomTabLayoutRegressionTest { assertEquals( "the camera rebound while the strip was still moving", startMode, - activity.camConfig.currentMode, + activity.viewfinder.currentMode, ) } waitUntil(scenario, "the camera switched once the strip had settled") { - it.camConfig.currentMode == it.tabLayout.getTabAt(target)!!.tag + it.viewfinder.currentMode == it.tabLayout.getTabAt(target)!!.tag } } } @@ -254,7 +254,7 @@ class BottomTabLayoutRegressionTest { assertEquals( "the mode the strip was settling into never reached the camera", tabs.getTabAt(target)!!.tag, - activity.camConfig.currentMode, + activity.viewfinder.currentMode, ) } } @@ -278,7 +278,7 @@ class BottomTabLayoutRegressionTest { var travel = 0 scenario.onActivity { activity -> val tabs = activity.tabLayout - startMode = activity.camConfig.currentMode + startMode = activity.viewfinder.currentMode target = nextTo(tabs.selectedTabPosition, tabs.tabCount) travel = (centreOf(tabs, target) - tabs.scrollX) * 2 / 3 } @@ -288,14 +288,14 @@ class BottomTabLayoutRegressionTest { assertEquals( "the camera switched before there was anything to check", startMode, - activity.camConfig.currentMode, + activity.viewfinder.currentMode, ) prefetched = activity.lastFrame assertNotNull("the drag started no copy of the preview", prefetched) } waitUntil(scenario, "the camera switched to tab $target") { - it.camConfig.currentMode == it.tabLayout.getTabAt(target)!!.tag + it.viewfinder.currentMode == it.tabLayout.getTabAt(target)!!.tag } scenario.onActivity { assertSame( @@ -325,7 +325,7 @@ class BottomTabLayoutRegressionTest { var travel = 0 scenario.onActivity { activity -> val tabs = activity.tabLayout - startMode = activity.camConfig.currentMode + startMode = activity.viewfinder.currentMode target = nextTo(tabs.selectedTabPosition, tabs.tabCount) travel = (centreOf(tabs, target) - tabs.scrollX) * 2 / 3 } @@ -334,7 +334,7 @@ class BottomTabLayoutRegressionTest { assertEquals( "the camera rebound before there was anything to check", startMode, - activity.camConfig.currentMode, + activity.viewfinder.currentMode, ) assertEquals( "the preview was left live over the freeze the switch is about to cost", @@ -360,7 +360,7 @@ class BottomTabLayoutRegressionTest { val tabs = activity.tabLayout // Started against a surface the rebind has just emptied, so the copy comes back // with nothing and schedules the retry this is about. - activity.camConfig.switchMode( + activity.viewfinder.switchMode( tabs.getTabAt(nextTo(tabs.selectedTabPosition, tabs.tabCount))!!.tag as CameraMode ) @@ -371,7 +371,7 @@ class BottomTabLayoutRegressionTest { Thread.sleep(RETRY_WINDOW_MS) scenario.moveToState(Lifecycle.State.RESUMED) - waitUntil(scenario, "the camera came back") { it.camConfig.camera != null } + waitUntil(scenario, "the camera came back") { it.session.camera != null } } } @@ -392,7 +392,7 @@ class BottomTabLayoutRegressionTest { // The rebind that applies the new ratio runs whole inside one message, so a poll // that sees the mode has landed sees a layout the resize has already asked for. waitUntil(scenario, "the preview took $mode's aspect ratio") { - it.camConfig.currentMode == mode && !it.previewView.isLayoutRequested + it.viewfinder.currentMode == mode && !it.previewView.isLayoutRequested } scenario.onActivity { activity -> @@ -431,14 +431,14 @@ class BottomTabLayoutRegressionTest { var startMode: CameraMode? = null scenario.onActivity { activity -> val tabs = activity.tabLayout - startMode = activity.camConfig.currentMode + startMode = activity.viewfinder.currentMode activity.finalizeMode(tabs.getTabAt(nextTo(tabs.selectedTabPosition, tabs.tabCount))) assertEquals( "the switch reached the camera before it could be taken back", startMode, - activity.camConfig.currentMode, + activity.viewfinder.currentMode, ) assertEquals( "the switch put up no transition to strand", @@ -458,7 +458,7 @@ class BottomTabLayoutRegressionTest { assertEquals( "the camera left the mode the strip came back to", startMode, - it.camConfig.currentMode, + it.viewfinder.currentMode, ) } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index 810575aca..344479d82 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt @@ -42,10 +42,10 @@ class CameraModeTabsRegressionTest { awaitModeTabs(scenario) scenario.onActivity { activity -> - activity.camConfig.switchMode(CameraMode.VIDEO) + activity.viewfinder.switchMode(CameraMode.VIDEO) assertEquals(CameraMode.VIDEO, activity.tabLayout.selectedTab?.tag) - activity.camConfig.switchMode(CameraMode.CAMERA) + activity.viewfinder.switchMode(CameraMode.CAMERA) assertEquals(CameraMode.CAMERA, activity.tabLayout.selectedTab?.tag) } } @@ -60,7 +60,7 @@ class CameraModeTabsRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } // Counting passes rather than reading isLayoutRequested: a request issued from inside a // layout pass is parked for the next traversal and the flag is cleared on the way, so it @@ -116,7 +116,7 @@ class CameraModeTabsRegressionTest { scenario.onActivity { activity -> val tabs = activity.tabLayout val next = tabs.getTabAt(tabs.selectedTabPosition + 1) - assertNotNull("no mode to the left of ${activity.camConfig.currentMode}", next) + assertNotNull("no mode to the left of ${activity.viewfinder.currentMode}", next) nextMode = next!!.tag as CameraMode flingLeft(activity) @@ -125,26 +125,26 @@ class CameraModeTabsRegressionTest { // The strip slides to the new mode before the camera rebinds, so the switch lands a // few frames after the fling rather than inside it. waitUntil(scenario, "the fling switched the mode to $nextMode") { - it.camConfig.currentMode == nextMode + it.viewfinder.currentMode == nextMode } } launchCaptureSession(CaptureActivity::class.java, MediaStore.ACTION_IMAGE_CAPTURE) .use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } // Flinging before the tabs were built would pass whether or not any get built Thread.sleep(TAB_BUILD_DWELL_MS) scenario.onActivity { activity -> flingLeft(activity) - assertEquals(CameraMode.CAMERA, activity.camConfig.currentMode) + assertEquals(CameraMode.CAMERA, activity.viewfinder.currentMode) } } } private fun assertNoModeTabs(scenario: ActivityScenario) { - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } // Asserting straight after the bind would also hold while a build was merely still // pending, so outlast the extension probe round that used to precede one. diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index f5a7ce07a..c6073f870 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -3,9 +3,13 @@ package app.grapheneos.camera import android.Manifest import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.camera.session.SnapshotProbeCache import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.di.camera.SnapshotProbeCacheEntryPoint import app.grapheneos.camera.ui.activities.MainActivity +import dagger.hilt.android.EntryPointAccessors import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull @@ -25,6 +29,15 @@ class ModeSwitchLatencyRegressionTest { @get:Rule val screenAwake = ScreenAwakeRule() + private val snapshotProbeCache: SnapshotProbeCache by lazy { + EntryPointAccessors + .fromApplication( + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext, + SnapshotProbeCacheEntryPoint::class.java, + ) + .snapshotProbeCache() + } + /** * Asking whether video, photo and preview can be bound together costs the camera service most * of a tenth of a second, and the answer is the same every time the user goes back to video @@ -37,23 +50,27 @@ class ModeSwitchLatencyRegressionTest { var snapshotsFirstTime = false scenario.onActivity { - CamConfig.clearSnapshotProbeCache() + snapshotProbeCache.clear() - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.VIDEO) - assertEquals("entering video mode did not probe", 1, CamConfig.snapshotProbeCount) - snapshotsFirstTime = it.camConfig.imageCapture != null + assertEquals( + "entering video mode did not probe", + 1, + snapshotProbeCache.probeCount, + ) + snapshotsFirstTime = it.session.imageCapture != null } scenario.onActivity { - it.camConfig.switchMode(CameraMode.CAMERA) - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.CAMERA) + it.viewfinder.switchMode(CameraMode.VIDEO) - assertEquals("the second entry probed again", 1, CamConfig.snapshotProbeCount) + assertEquals("the second entry probed again", 1, snapshotProbeCache.probeCount) assertEquals( "the cached verdict answered for a different snapshot decision", snapshotsFirstTime, - it.camConfig.imageCapture != null + it.session.imageCapture != null ) } } @@ -72,8 +89,8 @@ class ModeSwitchLatencyRegressionTest { awaitModeTabs(scenario) scenario.onActivity { - CamConfig.clearSnapshotProbeCache() - it.camConfig.switchMode(CameraMode.VIDEO) + snapshotProbeCache.clear() + it.viewfinder.switchMode(CameraMode.VIDEO) } // The qualities a camera records at are listed once its preview starts streaming, @@ -85,10 +102,11 @@ class ModeSwitchLatencyRegressionTest { scenario.onActivity { activity -> val spinner = activity.settingsDialog.videoQualitySpinner assumeTrue( - "this camera records at a single quality", spinner.count > 1 + "this camera records at a single quality", + spinner.count > 1, ) - val quality = activity.camConfig.videoQuality + val quality = activity.viewfinder.videoQuality val original = spinner.selectedItemPosition try { @@ -97,12 +115,12 @@ class ModeSwitchLatencyRegressionTest { assertNotEquals( "the video quality did not change", quality, - activity.camConfig.videoQuality + activity.viewfinder.videoQuality ) assertEquals( "the new quality was answered from the old verdict", 2, - CamConfig.snapshotProbeCount + snapshotProbeCache.probeCount ) } finally { selectVideoQuality(activity, original) @@ -120,16 +138,18 @@ class ModeSwitchLatencyRegressionTest { fun bindingACamera_doesNotReadTheZoomStateOnTheBindPath() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - waitUntil(scenario, "the zoom state is attached") { it.camConfig.zoomState != null } + waitUntil(scenario, "the zoom state is attached") { + it.session.zoomState != null + } scenario.onActivity { - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.VIDEO) - assertNull("the bind read the zoom state", it.camConfig.zoomState) + assertNull("the bind read the zoom state", it.session.zoomState) } waitUntil(scenario, "the zoom state is attached again") { - it.camConfig.zoomState != null + it.session.zoomState != null } } } @@ -143,16 +163,18 @@ class ModeSwitchLatencyRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } - waitUntil(scenario, "the zoom state is attached") { it.camConfig.zoomState != null } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } + waitUntil(scenario, "the zoom state is attached") { + it.session.zoomState != null + } - scenario.onActivity { it.camConfig.camera!!.cameraControl.setLinearZoom(0.5f) } + scenario.onActivity { it.session.camera!!.cameraControl.setLinearZoom(0.5f) } waitUntil(scenario, "the zoom bar caught up with the camera") { it.zoomBar.progress == 50 } scenario.onActivity { - assertEquals(0.5f, it.camConfig.zoomState!!.linearZoom, 0.01f) + assertEquals(0.5f, it.session.zoomState!!.linearZoom, 0.01f) } } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeTabs.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeTabs.kt index fab9f1227..5a333734a 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeTabs.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeTabs.kt @@ -9,6 +9,6 @@ import app.grapheneos.camera.ui.activities.MainActivity * fling handlers that read the tab model -- has to wait for both. */ fun awaitModeTabs(scenario: ActivityScenario) { - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } waitUntil(scenario, "mode tabs are built") { it.tabLayout.tabCount > 0 } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt index a5fec25c9..b1c670910 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt @@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.MoreSettings +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Rule @@ -37,11 +38,11 @@ class PhotoQualityRegressionTest { @Test fun photoQualityOutOfRange_isNotCommitted() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } // The same instance More Settings edits, so it can be read while that screen is up - val camConfig = camConfigOf(scenario) - val stored = camConfig.photoQuality + val viewfinder = viewfinderOf(scenario) + val stored = viewfinder.photoQuality val settings = openMoreSettings(scenario) val field = settings.findViewById(R.id.photo_quality) @@ -53,7 +54,7 @@ class PhotoQualityRegressionTest { instrumentation.callActivityOnPause(settings) - assertEquals(stored, camConfig.photoQuality) + assertEquals(stored, viewfinder.photoQuality) assertEquals(stored.toString(), field.text.toString()) } } @@ -63,10 +64,10 @@ class PhotoQualityRegressionTest { @Test fun photoQualityInRange_isCommitted() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } - val camConfig = camConfigOf(scenario) - val stored = camConfig.photoQuality + val viewfinder = viewfinderOf(scenario) + val stored = viewfinder.photoQuality val settings = openMoreSettings(scenario) val field = settings.findViewById(R.id.photo_quality) @@ -76,18 +77,18 @@ class PhotoQualityRegressionTest { field.setText("77") instrumentation.callActivityOnPause(settings) - assertEquals(77, camConfig.photoQuality) + assertEquals(77, viewfinder.photoQuality) } } finally { - camConfig.photoQuality = stored + viewfinder.photoQuality = stored } } } - private fun camConfigOf(scenario: ActivityScenario): CamConfig { - lateinit var camConfig: CamConfig - scenario.onActivity { camConfig = it.camConfig } - return camConfig + private fun viewfinderOf(scenario: ActivityScenario): ViewfinderController { + lateinit var viewfinder: ViewfinderController + scenario.onActivity { viewfinder = it.viewfinder } + return viewfinder } private fun openMoreSettings(scenario: ActivityScenario): MoreSettings { diff --git a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt index 32bd06833..a356f93d4 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -11,7 +11,9 @@ import app.grapheneos.camera.data.core.store.InMemoryDataStore import app.grapheneos.camera.data.media.repository.CapturedItemRepositoryImpl import app.grapheneos.camera.data.media.store.MediaPrefs import app.grapheneos.camera.data.media.store.StoragePrefs +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -22,6 +24,7 @@ import org.junit.runner.RunWith * one off the tracked list, nothing released it and the app kept indefinite read/write access to a * folder it no longer had any use for. */ +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SafTreeGrantsRegressionTest { @@ -44,6 +47,7 @@ class SafTreeGrantsRegressionTest { storagePrefs = InMemoryDataStore(StoragePrefs()), mediaPrefs = InMemoryDataStore(MediaPrefs()), context = context, + ioDispatcher = UnconfinedTestDispatcher(), ) } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt index d00eff719..8e054b356 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SecurePrefsIsolationTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.model.ModeSlot import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.data.settings.store.SettingsPrefs import app.grapheneos.camera.di.preferences.DurableSettingsPrefsEntryPoint @@ -117,8 +118,8 @@ class SecurePrefsIsolationTest { fun aSecureSessionKeepsItsModeSettingsToItselfAndThenKeepsThem() { asTheOwner { repository -> runBlocking { - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(false) + repository.modeSettings(SLOT) + repository.setGeoTagging(SLOT, false) } } @@ -127,9 +128,9 @@ class SecurePrefsIsolationTest { val repository = activity.settingsRepository val slotted = runBlocking { - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(true) - repository.selectMode(mode = MODE, isFrontFacing = false) + repository.modeSettings(SLOT) + repository.setGeoTagging(SLOT, true) + repository.modeSettings(SLOT) } assertTrue( @@ -160,6 +161,7 @@ class SecurePrefsIsolationTest { private companion object { val MODE = CameraMode.VIDEO + val SLOT = ModeSlot(mode = MODE, isFrontFacing = false) const val OWNERS_QUALITY = 71 const val SESSIONS_QUALITY = 42 diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index 2d9f828bd..f1362a19a 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt @@ -35,14 +35,14 @@ class SelfTimerRegressionTest { @Test fun selfTimer_isCancelledWhenTheActivityPauses() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } // Only a resumed activity can be paused: moveToState below would otherwise be a no-op // and the test would pass without exercising the fix. assertEquals(Lifecycle.State.RESUMED, scenario.state) scenario.onActivity { activity -> - activity.camConfig.switchMode(CameraMode.CAMERA) + activity.viewfinder.switchMode(CameraMode.CAMERA) activity.timerDuration = 10 activity.cdTimer.startTimer() assertTrue(activity.cdTimer.isRunning) @@ -70,7 +70,7 @@ class SelfTimerRegressionTest { @Test fun cancelTimer_withNoCountdownRunning_leavesQrModeControlsHidden() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } // A countdown has to have run at least once for the bug to be reachable: the guard this // replaced asked whether a timer had ever been built, not whether one was up. @@ -81,8 +81,8 @@ class SelfTimerRegressionTest { assertFalse(activity.cdTimer.isRunning) } - scenario.onActivity { it.camConfig.switchMode(CameraMode.QR_SCAN) } - waitUntil(scenario, "QR mode is active") { it.camConfig.isQRMode } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.QR_SCAN) } + waitUntil(scenario, "QR mode is active") { it.viewfinder.isQRMode } scenario.onActivity { activity -> activity.cdTimer.cancelTimer() @@ -98,10 +98,10 @@ class SelfTimerRegressionTest { @Test fun selfTimerBadge_tracksThePendingDurationInPhotoMode() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> - activity.camConfig.switchMode(CameraMode.CAMERA) + activity.viewfinder.switchMode(CameraMode.CAMERA) activity.timerDuration = 5 activity.updateSelfTimerBadge() @@ -124,10 +124,10 @@ class SelfTimerRegressionTest { @Test fun captureButton_withATimerSet_startsTheCountdownInsteadOfCapturing() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> - activity.camConfig.switchMode(CameraMode.CAMERA) + activity.viewfinder.switchMode(CameraMode.CAMERA) activity.timerDuration = 10 activity.updateSelfTimerBadge() @@ -152,10 +152,10 @@ class SelfTimerRegressionTest { @Test fun captureButton_duringACountdown_cancelsItAndPutsTheControlsBack() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> - activity.camConfig.switchMode(CameraMode.CAMERA) + activity.viewfinder.switchMode(CameraMode.CAMERA) activity.timerDuration = 10 activity.updateSelfTimerBadge() val shutterDescription = activity.captureButton.contentDescription @@ -183,13 +183,13 @@ class SelfTimerRegressionTest { @Test fun selfTimerBadge_staysHiddenWhereNoPhotoCanBeTaken() { ActivityScenario.launch(VideoOnlyActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.timerDuration = 5 activity.updateSelfTimerBadge() - assertTrue(activity.camConfig.isVideoMode) + assertTrue(activity.viewfinder.isVideoMode) assertEquals(View.INVISIBLE, activity.cbText.visibility) } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt index 38ec2868c..d6e954d9b 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt @@ -32,7 +32,7 @@ class SettingsDialogRegressionTest { @Test fun back_dismissesTheSettingsPanel() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.settingsDialog.show() @@ -57,7 +57,7 @@ class SettingsDialogRegressionTest { @Test fun backIsIntercepted_onlyWhileTheSettingsPanelIsUp() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> assertFalse(activity.onBackPressedDispatcher.hasEnabledCallbacks()) diff --git a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt index c5ad6ce07..f9860dc01 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt @@ -23,6 +23,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import app.grapheneos.camera.capturer.deleteStalePendingRecordings import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.media.store.videoCollectionUri import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity @@ -79,7 +80,7 @@ class VideoCapturerRegressionTest { fun startRecording_toleratesDuplicateStartSoundCallback() { recordingTest { scenario -> scenario.onActivity { activity -> - activity.camConfig.mPlayer = DoubleFiringTunePlayer(activity) + activity.viewfinder.mPlayer = DoubleFiringTunePlayer(activity) activity.videoCapturer.startRecording() } @@ -102,7 +103,7 @@ class VideoCapturerRegressionTest { lateinit var player: ManualTunePlayer scenario.onActivity { activity -> player = ManualTunePlayer(activity) - activity.camConfig.mPlayer = player + activity.viewfinder.mPlayer = player activity.videoCapturer.startRecording() assertTrue(activity.videoCapturer.isRecording) } @@ -117,7 +118,7 @@ class VideoCapturerRegressionTest { // A fresh recording must still work after the abandoned one. scenario.onActivity { activity -> - activity.camConfig.mPlayer = ImmediateTunePlayer(activity) + activity.viewfinder.mPlayer = ImmediateTunePlayer(activity) activity.videoCapturer.startRecording() } waitUntil(scenario, "recording is running") { it.videoCapturer.isRecording } @@ -133,7 +134,7 @@ class VideoCapturerRegressionTest { lateinit var player: ManualTunePlayer scenario.onActivity { activity -> player = ManualTunePlayer(activity) - activity.camConfig.mPlayer = player + activity.viewfinder.mPlayer = player activity.videoCapturer.startRecording() activity.videoCapturer.isPaused = true player.deferred!!.run() @@ -169,7 +170,7 @@ class VideoCapturerRegressionTest { lateinit var player: ManualTunePlayer scenario.onActivity { activity -> player = ManualTunePlayer(activity) - activity.camConfig.mPlayer = player + activity.viewfinder.mPlayer = player activity.videoCapturer.startRecording() activity.videoCapturer.isPaused = true player.deferred!!.run() @@ -200,7 +201,7 @@ class VideoCapturerRegressionTest { dp16 = 16 * activity.resources.displayMetrics.density val selector = StateListDrawable().apply { addState(StateSet.WILD_CARD, shape) } activity.captureButton.setImageDrawable(LayerDrawable(arrayOf(selector))) - activity.camConfig.mPlayer = ImmediateTunePlayer(activity) + activity.viewfinder.mPlayer = ImmediateTunePlayer(activity) activity.videoCapturer.startRecording() } @@ -223,7 +224,7 @@ class VideoCapturerRegressionTest { recordingTest { scenario -> scenario.onActivity { activity -> activity.captureButton.setImageDrawable(ColorDrawable(Color.RED)) - activity.camConfig.mPlayer = ImmediateTunePlayer(activity) + activity.viewfinder.mPlayer = ImmediateTunePlayer(activity) activity.videoCapturer.startRecording() } waitUntil(scenario, "recording UI is shown") { @@ -275,14 +276,16 @@ class VideoCapturerRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } - waitUntil(scenario, "video use case is bound") { it.camConfig.videoCapture != null } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } + waitUntil(scenario, "video use case is bound") { + it.session.videoCapture != null + } val capturedBefore = lastCapturedUri(scenario) try { scenario.onActivity { activity -> - activity.camConfig.mPlayer = ImmediateTunePlayer(activity) + activity.viewfinder.mPlayer = ImmediateTunePlayer(activity) activity.videoCapturer.startRecording() } waitUntil(scenario, "recording is running") { it.videoCapturer.isRecording } @@ -294,12 +297,12 @@ class VideoCapturerRegressionTest { // cannot pass merely because there was nowhere to go. val tabs = activity.tabLayout assertNotNull( - "no mode to the right of ${activity.camConfig.currentMode}", + "no mode to the right of ${activity.viewfinder.currentMode}", tabs.getTabAt(tabs.selectedTabPosition - 1) ) flingRight(activity) - mode = activity.camConfig.currentMode + mode = activity.viewfinder.currentMode stillRecording = activity.videoCapturer.isRecording } @@ -322,7 +325,7 @@ class VideoCapturerRegressionTest { recordingTest({ ActivityScenario.launch(intent) }) { scenario -> scenario.onActivity { activity -> - activity.camConfig.mPlayer = ImmediateTunePlayer(activity) + activity.viewfinder.mPlayer = ImmediateTunePlayer(activity) activity.videoCapturer.startRecording() } waitUntil(scenario, "recording is running") { it.videoCapturer.isRecording } @@ -350,11 +353,13 @@ class VideoCapturerRegressionTest { @Test fun tappingAModeTabDuringTheDeferredStart_leavesTheModeAlone() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } waitUntil(scenario, "mode tabs are built") { it.tabLayout.tabCount > 0 } - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } - waitUntil(scenario, "video use case is bound") { it.camConfig.videoCapture != null } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } + waitUntil(scenario, "video use case is bound") { + it.session.videoCapture != null + } lateinit var player: ManualTunePlayer var mode: CameraMode? = null @@ -364,7 +369,7 @@ class VideoCapturerRegressionTest { try { scenario.onActivity { activity -> player = ManualTunePlayer(activity) - activity.camConfig.mPlayer = player + activity.viewfinder.mPlayer = player activity.videoCapturer.startRecording() assertTrue(activity.videoCapturer.isRecording) @@ -374,7 +379,7 @@ class VideoCapturerRegressionTest { // What both of the strip's touch listeners do with a tap activity.finalizeMode(cameraTab) - mode = activity.camConfig.currentMode + mode = activity.viewfinder.currentMode highlighted = activity.tabLayout.selectedTab?.tag as CameraMode? } @@ -408,7 +413,7 @@ class VideoCapturerRegressionTest { ) { launch().use { scenario -> waitUntil(scenario, "video use case is bound") { - it.camConfig.camera != null && it.camConfig.videoCapture != null + it.session.camera != null && it.session.videoCapture != null } val capturedBefore = lastCapturedUri(scenario) try { @@ -443,7 +448,7 @@ class VideoCapturerRegressionTest { put(MediaColumns.MIME_TYPE, "video/mp4") put(MediaColumns.IS_PENDING, 1) } - return targetContext.contentResolver.insert(CamConfig.videoCollectionUri, values)!! + return targetContext.contentResolver.insert(videoCollectionUri, values)!! } /** Deleting an entry the reaper already took throws, so only clean up what is still there. */ @@ -472,20 +477,20 @@ class VideoCapturerRegressionTest { " AND ${MediaColumns.DISPLAY_NAME} LIKE '$VIDEO_NAME_PREFIX%'", ) } - return resolver.query(CamConfig.videoCollectionUri, arrayOf(MediaColumns._ID), args, null) + return resolver.query(videoCollectionUri, arrayOf(MediaColumns._ID), args, null) ?.use { it.count } ?: 0 } private fun lastCapturedUri(scenario: ActivityScenario): Uri? { var uri: Uri? = null - scenario.onActivity { uri = it.camConfig.lastCapturedItem?.uri } + scenario.onActivity { uri = it.capturedItemSession.lastCapturedItem?.uri } return uri } /** Keeps test runs from accumulating videos on the device. */ private fun deleteNewCapture(scenario: ActivityScenario, previous: Uri?) { scenario.onActivity { activity -> - val item = activity.camConfig.lastCapturedItem ?: return@onActivity + val item = activity.capturedItemSession.lastCapturedItem ?: return@onActivity if (item.uri != previous) { try { activity.contentResolver.delete(item.uri, null, null) diff --git a/app/src/debug/java/app/grapheneos/camera/di/camera/SnapshotProbeCacheEntryPoint.kt b/app/src/debug/java/app/grapheneos/camera/di/camera/SnapshotProbeCacheEntryPoint.kt new file mode 100644 index 000000000..89bc44031 --- /dev/null +++ b/app/src/debug/java/app/grapheneos/camera/di/camera/SnapshotProbeCacheEntryPoint.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.di.camera + +import app.grapheneos.camera.data.camera.session.SnapshotProbeCache +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface SnapshotProbeCacheEntryPoint { + fun snapshotProbeCache(): SnapshotProbeCache +} diff --git a/app/src/main/java/app/grapheneos/camera/ActivityLifeCycleHelper.kt b/app/src/main/java/app/grapheneos/camera/ActivityLifeCycleHelper.kt index 4b8f07e51..83e433f43 100644 --- a/app/src/main/java/app/grapheneos/camera/ActivityLifeCycleHelper.kt +++ b/app/src/main/java/app/grapheneos/camera/ActivityLifeCycleHelper.kt @@ -4,24 +4,30 @@ import android.app.Activity import android.app.Application import android.os.Bundle import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.SecureActivity class ActivityLifeCycleHelper( - private val callback: (activity: MainActivity?) -> Unit + private val onResumedActivityChanged: (activity: MainActivity?) -> Unit, + private val onSecureActivityCountChanged: (opened: Boolean) -> Unit, ) : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is SecureActivity) { + onSecureActivityCountChanged(true) + } + } override fun onActivityStarted(activity: Activity) {} override fun onActivityResumed(activity: Activity) { if (activity is MainActivity) { - callback.invoke(activity) + onResumedActivityChanged(activity) } } override fun onActivityPaused(activity: Activity) { if (activity is MainActivity) { - callback.invoke(null) + onResumedActivityChanged(null) } } @@ -29,5 +35,9 @@ class ActivityLifeCycleHelper( override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) {} + override fun onActivityDestroyed(activity: Activity) { + if (activity is SecureActivity) { + onSecureActivityCountChanged(false) + } + } } diff --git a/app/src/main/java/app/grapheneos/camera/App.kt b/app/src/main/java/app/grapheneos/camera/App.kt index 94559026a..6c9d8d19b 100644 --- a/app/src/main/java/app/grapheneos/camera/App.kt +++ b/app/src/main/java/app/grapheneos/camera/App.kt @@ -13,10 +13,12 @@ import android.view.WindowManager import androidx.annotation.RequiresPermission import androidx.appcompat.app.AppCompatActivity import app.grapheneos.camera.capturer.deleteStalePendingRecordings +import app.grapheneos.camera.di.preferences.SecureSessionPreferences import app.grapheneos.camera.ui.activities.MainActivity import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import java.util.concurrent.TimeUnit +import javax.inject.Inject import kotlin.concurrent.thread @HiltAndroidApp @@ -68,11 +70,25 @@ class App : Application() { } } + @Inject + internal lateinit var secureSessionPreferences: SecureSessionPreferences + private val activityLifeCycleHelper by lazy { - ActivityLifeCycleHelper { activity -> - if (activity != null) activity.disableAutoSleep() else this.activity?.enableAutoSleep() - this.activity = activity - } + ActivityLifeCycleHelper( + onResumedActivityChanged = { activity -> + when { + activity != null -> activity.disableAutoSleep() + else -> this.activity?.enableAutoSleep() + } + this.activity = activity + }, + onSecureActivityCountChanged = { opened -> + when { + opened -> secureSessionPreferences.onSecureActivityCreated() + else -> secureSessionPreferences.onSecureActivityDestroyed() + } + }, + ) } fun isAnyLocationProvideActive(): Boolean { @@ -161,9 +177,12 @@ class App : Application() { private fun isLocationEnabled(): Boolean = locationManager.isLocationEnabled - fun shouldAskForLocationPermission() = - checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && - checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED + fun shouldAskForLocationPermission(): Boolean { + return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != + PackageManager.PERMISSION_GRANTED && + checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != + PackageManager.PERMISSION_GRANTED + } override fun onTerminate() { super.onTerminate() diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt deleted file mode 100644 index 5098f5030..000000000 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ /dev/null @@ -1,1932 +0,0 @@ -package app.grapheneos.camera - -import android.annotation.SuppressLint -import android.hardware.camera2.CameraCharacteristics -import android.net.Uri -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.provider.MediaStore -import android.util.Log -import android.util.Size -import android.view.MotionEvent -import android.view.View -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.widget.Button -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AlertDialog -import androidx.camera.camera2.interop.Camera2CameraInfo -import androidx.camera.camera2.interop.ExperimentalCamera2Interop -import androidx.camera.core.AspectRatio -import androidx.camera.core.Camera -import androidx.camera.core.CameraInfo -import androidx.camera.core.CameraSelector -import androidx.camera.core.DynamicRange -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture -import androidx.camera.core.MirrorMode -import androidx.camera.core.Preview -import androidx.camera.core.SessionConfig -import androidx.camera.core.TorchState -import androidx.camera.core.UseCase -import androidx.camera.core.ZoomState -import androidx.camera.core.featuregroup.GroupableFeature -import androidx.camera.core.resolutionselector.AspectRatioStrategy -import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.core.resolutionselector.ResolutionStrategy -import androidx.camera.extensions.ExtensionMode -import androidx.camera.extensions.ExtensionsManager -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.GroupableFeatures -import androidx.camera.video.Quality -import androidx.camera.video.QualitySelector -import androidx.camera.video.Recorder -import androidx.camera.video.VideoCapture -import androidx.camera.video.internal.muxer.MediaMuxerImpl -import androidx.core.content.ContextCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import app.grapheneos.camera.analyzer.QRAnalyzer -import app.grapheneos.camera.data.core.model.CameraMode -import app.grapheneos.camera.data.media.repository.CapturedItemRepository -import app.grapheneos.camera.data.settings.model.CameraSettings -import app.grapheneos.camera.data.settings.model.GridType -import app.grapheneos.camera.data.settings.model.ModeSettings -import app.grapheneos.camera.data.settings.model.SettingsDefaults -import app.grapheneos.camera.data.settings.model.focusTimeoutLabel -import app.grapheneos.camera.ui.videoQualityTitle -import app.grapheneos.camera.data.settings.repository.SettingsRepository -import app.grapheneos.camera.ktx.applyPreviewRatio -import app.grapheneos.camera.ui.activities.CaptureActivity -import app.grapheneos.camera.ui.activities.MainActivity -import app.grapheneos.camera.ui.activities.MoreSettings -import app.grapheneos.camera.ui.activities.SecureActivity -import app.grapheneos.camera.ui.activities.SecureMainActivity -import app.grapheneos.camera.ui.activities.VideoCaptureActivity -import app.grapheneos.camera.ui.activities.VideoOnlyActivity -import app.grapheneos.camera.ui.showIgnoringShortEdgeMode -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.BarcodeFormat -import java.io.IOException -import java.util.concurrent.ExecutionException -import java.util.concurrent.Executors -import kotlin.concurrent.thread -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -@SuppressLint("UnsafeOptInUsageError") -class CamConfig( - private val mActivity: MainActivity, - private val settingsRepository: SettingsRepository, - private val capturedItemRepository: CapturedItemRepository, -) { - - companion object { - private const val TAG = "CamConfig" - - private const val PREVIEW_SNAP_DURATION = 200L - private const val PREVIEW_SL_OVERLAY_DUR = 200L - - const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK - - val commonFormats = arrayOf( - BarcodeFormat.AZTEC, - BarcodeFormat.QR_CODE, - BarcodeFormat.DATA_MATRIX, - BarcodeFormat.PDF_417, - ) - - val imageCollectionUri: Uri = MediaStore.Images.Media.getContentUri( - MediaStore.VOLUME_EXTERNAL_PRIMARY - )!! - - val videoCollectionUri: Uri = MediaStore.Video.Media.getContentUri( - MediaStore.VOLUME_EXTERNAL_PRIMARY - )!! - - val DEFAULT_CAMERA_MODE = CameraMode.CAMERA - - val FRONT_CAMERA_SELECTOR = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_FRONT) - .build() - - val REAR_CAMERA_SELECTOR = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - // Whether a vendor extension is usable, keyed by lens facing and extension mode (see - // probeExtension). A verdict describes the device rather than any one activity and costs - // binder round trips to reach, so it is kept for the process: every lock screen launch used - // to re-probe what the session underneath it had already answered. - private val extensionUsability = HashMap, Boolean>() - - // Every setting that reaches one of the three probed SessionConfigs has to appear in the - // key. A setting added to the ImageCapture, Recorder or Preview builder without being added - // here would be answered from a verdict that predates it, which either takes in-video - // snapshots away for no reason or keeps them on a camera that cannot bind them. - private val snapshotDropReason = HashMap() - - // A cache hit and a repeated probe reach the same verdict, so this is the only thing that - // tells them apart from the outside. - @VisibleForTesting - var snapshotProbeCount = 0 - private set - - @VisibleForTesting - fun clearSnapshotProbeCache() { - snapshotDropReason.clear() - snapshotProbeCount = 0 - } - - // The provider the verdicts above were probed through. A different instance means the - // camera stack was reinitialized and none of them describe it any more. - private var probedCameraProvider: ProcessCameraProvider? = null - } - - private data class SnapshotProbeKey( - val lensFacing: Int, - val videoQuality: Quality, - val usesFeatureGroup: Boolean, - val captureMode: Int, - val selectHighestResolution: Boolean, - ) - - var camera: Camera? = null - - // Asking CameraInfo for the zoom state is cheap for a plain camera but costs ~100 ms once an - // extension is bound, because CameraX then queries the extension's zoom range through - // CameraExtensionCharacteristics, which enumerates every vendor key. Read this snapshot instead - // of the camera on any path that runs more than once per bind. It is null from the moment a - // bind starts until attachZoomState has run, which is where that query was moved to. - var zoomState: ZoomState? = null - private set - - private var zoomStateSource: LiveData? = null - - private val zoomStateObserver = Observer { - zoomState = it - if (it.linearZoom != 0f || it.zoomRatio != 1f) { - mActivity.zoomBar.updateThumb() - } - } - - private val handler = Handler(Looper.getMainLooper()) - - private val attachZoomState = Runnable { - if (mActivity.isDestroyed || mActivity.isFinishing) return@Runnable - - zoomStateSource = camera?.cameraInfo?.zoomState?.also { - it.observe(mActivity, zoomStateObserver) - } - - zoomState = zoomStateSource?.value - } - - var cameraProvider: ProcessCameraProvider? = null - private var extensionsManager: ExtensionsManager? = null - - var imageCapture: ImageCapture? = null - private set - - var preview: Preview? = null - - val allowedFormats: ArrayList = arrayListOf() - - private val cameraExecutor by lazy { - Executors.newSingleThreadExecutor() - } - - var videoCapture: VideoCapture? = null - - private var qrAnalyzer: QRAnalyzer? = null - - var iAnalyzer: ImageAnalysis? = null - - @set:VisibleForTesting - var mPlayer = TunePlayer(mActivity) - - private var settings: CameraSettings = runBlocking { settingsRepository.settings.first() } - - private var currentStorageLocation: String = runBlocking { - capturedItemRepository.storageLocation.first() - } - - private var modeSettings: ModeSettings = ModeSettings() - - private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) - - var lastCapturedItem: CapturedItem? = null - - init { - preferencesScope.launch { - settingsRepository.settings.collect { settings = it } - } - - preferencesScope.launch { - capturedItemRepository.storageLocation.collect { currentStorageLocation = it } - } - - if (mActivity !is SecureActivity) { - try { - runBlocking { - capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) - capturedItemRepository.releaseUntrackedSafTrees() - } - } catch (e: IOException) { - Log.e(TAG, "unable to migrate the stored captures", e) - } - - fetchLastCapturedItem() - } - } - - fun onDestroy() { - preferencesScope.cancel() - } - - fun fetchLastCapturedItem() { - val item = try { - runBlocking { capturedItemRepository.lastCapturedItem() } - } catch (e: IOException) { - Log.e(TAG, "unable to read the last captured item", e) - null - } - val skip = item?.type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity - - lastCapturedItem = if (skip) null else item - } - - - var isVideoMode = false - private set - get() { - return field || - mActivity is VideoCaptureActivity || - mActivity is VideoOnlyActivity - } - - val canTakePicture: Boolean - get() { - return imageCapture != null - } - - var isQRMode = false - private set - - val isFlashAvailable: Boolean - get() = camera?.cameraInfo?.hasFlashUnit() ?: false - - var isTorchOn: Boolean = false - get() { - return camera?.cameraInfo?.torchState?.value == TorchState.ON - } - set(value) { - field = if (isFlashAvailable) { - camera?.cameraControl?.enableTorch(value) - value - } else { - false - } - } - - var currentMode: CameraMode = DEFAULT_CAMERA_MODE - private set - - var aspectRatio: Int - get() { - return when { - isVideoMode -> { - AspectRatio.RATIO_16_9 - } - - isQRMode -> { - AspectRatio.RATIO_4_3 - } - - else -> { - settings.aspectRatio - } - } - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(aspectRatio = value) } - } - } - - var lensFacing = DEFAULT_LENS_FACING - - private var cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(DEFAULT_LENS_FACING) - .build() - - var gridType: GridType - get() { - return settings.gridType - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(gridType = value) } - } - } - - var videoQuality: Quality - get() { - return modeSettings.videoQuality - } - set(value) { - runBlocking { - settingsRepository.setVideoQuality(value) - }?.let { modeSettings = it } - } - - var flashMode: Int = SettingsDefaults.FLASH_MODE - private set - - fun setFlashMode(value: Int) { - runBlocking { - settingsRepository.setFlashMode(value) - }?.let { modeSettings = it } - - applyFlashMode(value) - } - - private fun applyFlashMode(value: Int) { - flashMode = value - imageCapture?.flashMode = value - mActivity.settingsDialog.updateFlashMode() - } - - var focusTimeout: Long - get() { - return settings.focusTimeoutSeconds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(focusTimeoutSeconds = value) } - } - } - - var selfTimerDuration: Int - get() { - return settings.selfTimerDurationSeconds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(selfTimerDurationSeconds = value) } - } - } - - var enableCameraSounds: Boolean - get() { - return settings.enableCameraSounds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableCameraSounds = value) } - } - } - - var scanAllCodes: Boolean - get() { - return settings.scanAllCodes - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(scanAllCodes = value) } - } - - if (isQRMode) { - if (value) { - mActivity.setFlipCameraIcon( - R.drawable.cancel, R.string.stop_scanning_all_formats - ) - mActivity.qrScanToggles.visibility = View.GONE - } else { - mActivity.setFlipCameraIcon( - R.drawable.auto, R.string.scan_all_formats - ) - mActivity.qrScanToggles.visibility = View.VISIBLE - } - } - - qrAnalyzer?.refreshHints() - } - - var includeAudio: Boolean - get() { - return settings.includeAudio - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(includeAudio = value) } - } - - mActivity.settingsDialog.includeAudioToggle.isChecked = value - } - - var enableEIS: Boolean - get() { - return settings.enableEis - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableEis = value) } - } - - mActivity.settingsDialog.enableEISToggle.isChecked = value - } - - var enableZsl: Boolean - get() { - return settings.enableZsl - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableZsl = value) } - } - } - - var saveImageAsPreviewed: Boolean - get() { - return settings.saveImageAsPreviewed - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(saveImageAsPreviewed = value) } - } - } - - var saveVideoAsPreviewed: Boolean - get() { - return settings.saveVideoAsPreviewed - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(saveVideoAsPreviewed = value) } - } - } - - var storageLocation: String - get() { - return currentStorageLocation - } - set(value) { - runBlocking { - capturedItemRepository.setStorageLocation(value) - capturedItemRepository.releaseUntrackedSafTrees() - } - - currentStorageLocation = value - } - - var photoQuality: Int - get() { - return settings.photoQuality - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(photoQuality = value) } - } - } - - var removeExifAfterCapture: Boolean - get() { - return settings.removeExifAfterCapture - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(removeExifAfterCapture = value) } - } - } - - var gSuggestions: Boolean - get() { - return settings.gyroscopeSuggestions - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(gyroscopeSuggestions = value) } - } - } - - val isZslSupported: Boolean by lazy { - camera!!.cameraInfo.isZslSupported - } - - // Whether the EIS toggle has anything to act on. Stabilization is only ever requested through - // the feature group (see startCamera), which in turn needs the platform to be able to verify - // feature combinations, so a camera that can stabilize is not on its own enough: where the - // group cannot be used nothing applies EIS, and offering the toggle there is offering a - // control that does nothing. - fun canApplyVideoStabilization(): Boolean { - return canVerifyFeatureCombinations() && isVideoStabilizationSupported() - } - - private fun isVideoStabilizationSupported(): Boolean { - // The toggle asks for both kinds of stabilization (see the preferred feature group in - // startCamera), preferring the preview kind and falling back to the recording-only kind, - // so it is meaningful whenever either one is available. Testing only the recorder - // capability both hid the toggle on cameras that can stabilize the preview but not the - // recording, and offered it on cameras where only the recorder can stabilize - where - // the bind used to ask exclusively for preview stabilization, leaving the toggle with - // nothing to do. - return isPreviewStabilizationSupported() || isRecorderStabilizationSupported() - } - - private fun isPreviewStabilizationSupported(): Boolean { - return Preview.getPreviewCapabilities(getCurrentCameraInfo()).isStabilizationSupported - } - - - private fun isRecorderStabilizationSupported(): Boolean { - return Recorder.getVideoCapabilities(getCurrentCameraInfo()).isStabilizationSupported - } - - fun shouldShowGyroscope(): Boolean { - return isInPhotoMode && gSuggestions - } - - private val isInPhotoMode: Boolean - get() { - return !(isQRMode || isVideoMode) - } - - val isInCaptureMode: Boolean - get() { - return mActivity is CaptureActivity - } - - fun updateLastCapturedItem(item: CapturedItem) { - lastCapturedItem = item - - try { - runBlocking { capturedItemRepository.saveLastCapturedItem(item) } - } catch (e: IOException) { - Log.e(TAG, "unable to store the last captured item", e) - } - } - - // Session state rather than the stored value: geo-tagging is only ever on once the permission - // is actually granted, and reloadSettings() is what settles a stored "on" against that. Reading - // the preference back here would resurrect the very stale "on" the coercion exists to drop. - var requireLocation: Boolean = false - set(value) { - mActivity.locationCamConfigChanged(value) - - // A permission result is delivered before the first onResume of an activity the system - // recreated, so this can run before a mode has been slotted — see modeSettings. - runBlocking { - settingsRepository.setGeoTagging(value) - }?.let { modeSettings = it } - - mActivity.settingsDialog.locToggle.isChecked = value - - field = value - } - - var selfIlluminate: Boolean - get() { - return modeSettings.selfIllumination && - lensFacing == CameraSelector.LENS_FACING_FRONT - } - set(value) { - runBlocking { - settingsRepository.setSelfIllumination(value) - }?.let { modeSettings = it } - - mActivity.settingsDialog.selfIlluminationToggle.isChecked = value - mActivity.settingsDialog.selfIllumination() - } - - private fun getString(@StringRes id: Int) = mActivity.getString(id) - - fun setQRScanningFor(format: String, selected: Boolean) { - - settings = runBlocking { - settingsRepository.update { - it.withBarcodeFormat(formatName = format, enabled = selected) - } - } - - if (selected) { - if (BarcodeFormat.valueOf(format) !in allowedFormats) { - allowedFormats.add(BarcodeFormat.valueOf(format)) - } - } else { - if (allowedFormats.size == 1) { - mActivity.showMessage( - getString(R.string.no_barcode_selected) - ) - } else { - allowedFormats.remove(BarcodeFormat.valueOf(format)) - } - } - - qrAnalyzer?.refreshHints() - } - - private fun slotCurrentMode() { - modeSettings = runBlocking { - settingsRepository.selectMode( - mode = currentMode, - isFrontFacing = lensFacing == CameraSelector.LENS_FACING_FRONT, - ) - } - } - - fun reloadSettings() { - slotCurrentMode() - - if (isVideoMode) { - mActivity.settingsDialog.reloadQualities() - } - - applyFlashMode(modeSettings.flashMode) - - // A stored "on" is written before a permission request resolves, and it outlives a later - // revocation, so it cannot be asserted on its own: doing so opened a permission dialog on - // startup that the user never asked for. Coercing it here settles the stale value through - // the setter, and leaves every dialog in the app originating from an explicit toggle. - requireLocation = modeSettings.geoTagging && - !(mActivity.applicationContext as App).shouldAskForLocationPermission() - - selfIlluminate = modeSettings.selfIllumination - - mActivity.settingsDialog.showOnlyRelevantSettings() - } - - fun loadSettings() { - mActivity.settingsDialog.updateGridToggleUI() - - mActivity.settingsDialog.updateFocusTimeout(focusTimeoutLabel(settings.focusTimeoutSeconds)) - - includeAudio = settings.includeAudio - - enableEIS = settings.enableEis - - allowedFormats.clear() - - for (format in BarcodeFormat.values()) { - if (format.name in settings.enabledBarcodeFormats) { - if (format !in allowedFormats) { - allowedFormats.add(format) - } - - if (format == BarcodeFormat.QR_CODE) { - mActivity.qrToggle.isSelected = true - } - - if (format == BarcodeFormat.AZTEC) { - mActivity.azToggle.isSelected = true - } - - if (format == BarcodeFormat.PDF_417) { - mActivity.cBToggle.isSelected = true - } - - if (format == BarcodeFormat.DATA_MATRIX) { - mActivity.dmToggle.isSelected = true - } - } - } - - qrAnalyzer?.refreshHints() - } - - var waitForFocusLock: Boolean - get() { - return settings.waitForFocusLock - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(waitForFocusLock = value) } - } - } - - var selectHighestResolution: Boolean - get() { - return settings.selectHighestResolution - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(selectHighestResolution = value) } - } - } - - fun toggleTorchState() { - isTorchOn = !isTorchOn - } - - fun toggleFlashMode() { - if (isFlashAvailable) { - - val next = when (flashMode) { - ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON - ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO - else -> ImageCapture.FLASH_MODE_OFF - } - - setFlashMode(next) - - } else { - mActivity.showMessage( - getString(R.string.flash_unavailable_in_selected_mode) - ) - } - } - - fun toggleAspectRatio() { - aspectRatio = if (aspectRatio == AspectRatio.RATIO_16_9) { - AspectRatio.RATIO_4_3 - } else { - AspectRatio.RATIO_16_9 - } - startCamera(true) - } - - private fun getCurrentCameraInfo(): CameraInfo { - return cameraProvider!!.getCameraInfo(cameraSelector) - } - - fun toggleCameraSelector() { - - // Manually switch to the opposite lens facing - lensFacing = - if (lensFacing == CameraSelector.LENS_FACING_BACK) - CameraSelector.LENS_FACING_FRONT - else - CameraSelector.LENS_FACING_BACK - - - // Test whether the new lens facing is supported by the current device - // If it is supported then restart the camera with the new configuration - if (isLensFacingSupported(lensFacing)) { - startCamera(true) - } else { - // Else revert back to the old facing (while displaying an error message - // to the user) - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - mActivity.showMessage(getString(R.string.rear_camera_unavailable)) - CameraSelector.LENS_FACING_FRONT - } else { - mActivity.showMessage(getString(R.string.front_camera_unavailable)) - CameraSelector.LENS_FACING_BACK - } - } - - } - - fun initializeCamera(forced: Boolean = false) { - if (cameraProvider != null) { - startCamera(forced = forced) - return - } - val cameraProviderFuture = ProcessCameraProvider.getInstance(mActivity) - - cameraProviderFuture.addListener(fun() { - val provider: ProcessCameraProvider - try { - provider = cameraProviderFuture.get() - } catch (e: ExecutionException) { - mActivity.showMessage(mActivity.getString(R.string.camera_provider_init_failure)) - return - } - - if (provider !== probedCameraProvider) { - // A different provider instance means the camera stack was reinitialized: - // extension verdicts probed through the previous instance (including bind-time - // blacklists, see startCamera) describe vendor state that no longer exists. - extensionUsability.clear() - snapshotDropReason.clear() - probedCameraProvider = provider - } - cameraProvider = provider - - // Manually switch to the other lens facing (if the default lens facing isn't - // supported for the current device) - if (!isLensFacingSupported(lensFacing)) { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK - } - } - - // Despite the name, getInstanceAsync() runs its one-time initialization body - // synchronously on the calling thread, and that body asks the vendor's extensions - // proxy service for each camera's advertised extensions until it finds one that has - // any -- measured at a handful of binder round trips on the main thread during - // startup on a Pixel 7 Pro. Call it from a short-lived thread instead; only the - // listener has to run on the main thread, for the field write and startCamera(). - thread { - try { - val extensionsManagerFuture = - ExtensionsManager.getInstanceAsync(mActivity, provider) - - extensionsManagerFuture.addListener({ - try { - extensionsManager = extensionsManagerFuture.get() - } catch (e: ExecutionException) { - mActivity.showMessage(mActivity.getString(R.string.extensions_manager_init_failure)) - } - startCamera(forced = forced) - }, ContextCompat.getMainExecutor(mActivity)) - } catch (e: Exception) { - // getInstanceAsync() runs its initialization synchronously (see above), so it - // -- or addListener -- can throw right here on this background thread, where an - // escaping exception would crash the process and, worse, leave the camera never - // started because the listener never runs. Recover exactly as the future-failure - // path does: report it and start the camera without extensions, on the main - // thread. - Log.e(TAG, "Extensions manager initialization failed", e) - ContextCompat.getMainExecutor(mActivity).execute { - mActivity.showMessage(mActivity.getString(R.string.extensions_manager_init_failure)) - startCamera(forced = forced) - } - } - } - - }, ContextCompat.getMainExecutor(mActivity)) - } - - // ExtensionsManager.isExtensionAvailable() answers from CameraExtensionCharacteristics' - // static advertisement data. Actually *binding* an advertised extension additionally makes - // CameraX initialize a Camera2ExtensionsVendorExtender, which calls into the vendor's - // advanced extender over binder. Some vendors advertise a mode there and then throw from - // that init - Pixels raise "Framework size list map not supported in pixel path" - // - which used to kill the process from inside bindToLifecycle(). CameraX 1.6 removed the - // legacy OEM extender path, so there is no longer a working fallback on those devices and the - // only safe option is to stop offering the mode. - // - // getCameraInfo() performs exactly the same vendor init that bindToLifecycle() does, so it is - // a faithful probe. Verdicts are cached (extensionUsability) because every step of the probe - // -- including the availability query, see probeExtension -- costs a binder round trip. A - // negative verdict is only cached when the failure is known to be persistent: caching a - // transient probe failure would make the mode's tab vanish for the rest of the process - // lifetime over a condition that clears seconds later. - // - // The cache is read and written on the main thread only, which is also what keeps the two - // activities that can share it (a secure session over a running one) from racing each other. - // probeExtension() itself also runs on the probe thread that loadTabs() spawns, but its - // results come back through the main executor. - // - // true/false is a verdict that is safe to cache; null is a failure that may be transient - // and must not be (see extensionUsability above). The provider and manager are parameters - // rather than the fields because this also runs off the main thread, where the fields could - // be swapped out mid-probe. - private fun probeExtension( - provider: ProcessCameraProvider, - em: ExtensionsManager, - selector: CameraSelector, - extensionMode: Int, - ): Boolean? { - // What the vendor advertises is as static as the vendor init verdict below, so a - // negative answer is cached the same way -- notably, isExtensionAvailable() is *not* - // the cheap in-process lookup it appears to be: on Android 17 every call costs a round - // trip to the vendor's extensions proxy service, which is exactly the kind of work this - // probe exists to keep off the main thread (see loadTabs). It sits inside the try below so - // that a transient failure of that round trip is treated like any other -- returning null - // to retry later -- rather than propagating out: on the background probe thread an escaping - // exception would strand the round with extensionProbesInFlight still set, blocking every - // later tab refresh. - return try { - when { - !em.isExtensionAvailable(selector, extensionMode) -> false - else -> { - provider.getCameraInfo( - em.getExtensionEnabledCameraSelector(selector, extensionMode) - ) - true - } - } - } catch (e: UnsupportedOperationException) { - // The signature of a vendor extender that advertises the mode and then throws from - // its own init (Pixels: "Framework size list map not supported in pixel path"). - // Nothing about it changes within a process lifetime, so this verdict is safe to - // remember. - Log.w(TAG, "Extension mode $extensionMode is advertised but unusable here", e) - false - } catch (e: Exception) { - // Anything else may be transient — the camera service restarting, the camera briefly - // held by another process. Fail this probe but leave the cache alone so the mode is - // offered again once the underlying condition clears. - Log.w(TAG, "Probing extension mode $extensionMode failed, will retry later", e) - null - } - } - - private fun isExtensionUsable( - selector: CameraSelector, - lensFacing: Int, - extensionMode: Int, - probeOnMiss: Boolean = true, - ): Boolean { - if (extensionMode == ExtensionMode.NONE) return true - - val em = extensionsManager ?: return false - val provider = cameraProvider ?: return false - - val key = lensFacing to extensionMode - extensionUsability[key]?.let { return it } - - if (!probeOnMiss) return false - - // A background probe round (loadTabs) may already be asking the vendor about this very - // key. Probing inline here would run a second synchronous vendor round trip on the main - // thread and race that round's verdict write. While a round is in flight, treat the miss - // as "unusable for now"; the round fills the cache and the next rebind/tab refresh picks - // up the real verdict. The inline probe stays for the not-in-flight cold case, where it is - // the only path to a verdict. - if (extensionProbesInFlight) return false - - val verdict = probeExtension(provider, em, selector, extensionMode) ?: return false - extensionUsability[key] = verdict - return verdict - } - - // Whether CameraX's feature-group resolution can actually *verify* feature combinations on - // the current camera. The resolver (DefaultFeatureGroupResolver) keeps a candidate - // combination only when the platform confirms it, and it treats "could not check" exactly - // like "verified unsupported": below API 35 CameraX substitutes a no-op feature-combination - // query whose isSupported() is unconditionally false (CameraSurfaceAdapter), and on API 35+ - // a HAL that does not implement the session-configuration query answers UNKNOWN, which is - // folded into false as well (camera-pipe ConfigQueryResult). On such a camera, combinations - // the device records fine every day resolve down to little or nothing: the stored video - // quality is silently ignored (setQualitySelector must not be called while a feature group - // is in use, see startCamera) and the divergence notice asserts unsupportedness that was - // never actually verified. So the feature-group path is only taken when the platform can - // genuinely answer, and startCamera otherwise falls back to the pre-1.6 configuration APIs, - // which never drop the chosen quality. - // - // The per-camera INFO_SESSION_CONFIGURATION_QUERY_VERSION characteristic is what the - // platform's answers ultimately hinge on (CameraDeviceSetup exists only for cameras - // reporting >= 35 there), so it is checked in addition to the API level. This deliberately - // mirrors CameraX's own SDK_INT >= 35 gate — which upstream may still move, see b/417839748 - // — plus the HAL capability that gate cannot see; re-check both on CameraX updates. - @androidx.annotation.OptIn(ExperimentalCamera2Interop::class) - private fun canVerifyFeatureCombinations(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return false - - val cameraInfo = try { - cameraProvider?.getCameraInfo(cameraSelector) ?: return false - } catch (exception: IllegalArgumentException) { - Log.w(TAG, "Unable to resolve camera info for feature combination gate", exception) - return false - } - - val queryVersion = try { - Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic( - CameraCharacteristics.INFO_SESSION_CONFIGURATION_QUERY_VERSION - ) - } catch (exception: IllegalArgumentException) { - Log.w(TAG, "Camera info carries no camera2 characteristics", exception) - null - } - - return (queryVersion ?: 0) >= Build.VERSION_CODES.VANILLA_ICE_CREAM - } - - // Maps the user-chosen video quality to the equivalent groupable feature, for use when a - // feature group is passed to SessionConfig (see startCamera). Quality.HIGHEST has no - // groupable equivalent and is resolved to the highest quality the current camera supports, - // mirroring what QualitySelector.from(Quality.HIGHEST) would have selected. - private fun videoQualityAsGroupableFeature(): GroupableFeature? { - val quality = if (videoQuality == Quality.HIGHEST) { - val cameraInfo = try { - cameraProvider?.getCameraInfo(cameraSelector) ?: return null - } catch (exception: IllegalArgumentException) { - Log.w(TAG, "Unable to resolve camera info for quality lookup", exception) - return null - } - Recorder.getVideoCapabilities(cameraInfo) - .getSupportedQualities(DynamicRange.SDR) - .firstOrNull() ?: return null - } else { - videoQuality - } - - return when (quality) { - Quality.UHD -> GroupableFeatures.UHD_RECORDING - Quality.FHD -> GroupableFeatures.FHD_RECORDING - Quality.HD -> GroupableFeatures.HD_RECORDING - Quality.SD -> GroupableFeatures.SD_RECORDING - else -> { - // Not fatal: startCamera() then requests no quality feature at all and the - // quality is left to Recorder's default selector. Worth a log because it means - // the user's explicit choice is silently not being asked for. - Log.w(TAG, "No groupable feature equivalent for video quality $quality") - null - } - } - } - - private fun describeQualityFeature(feature: GroupableFeature): String? { - val quality = when (feature) { - GroupableFeatures.UHD_RECORDING -> Quality.UHD - GroupableFeatures.FHD_RECORDING -> Quality.FHD - GroupableFeatures.HD_RECORDING -> Quality.HD - GroupableFeatures.SD_RECORDING -> Quality.SD - else -> return null - } - - return videoQualityTitle(mActivity, quality) - } - - // Avoids repeating an unchanged notice: startCamera() runs again on every tab switch, - // settings change, camera flip and resume, and the outcome is usually the same each time. - // Keyed by lens facing so that alternating between a fully-supported camera and a limited - // one doesn't re-announce the limited camera's unchanged hardware fact on every flip: a - // fully-satisfied bind clears only its own camera's entry, so the next divergence on that - // camera is genuinely new information while the other camera's stays remembered. - private val lastReportedDivergence = HashMap() - - // CameraX resolves a preferred feature group by dropping features until what is left is a - // combination the camera actually supports, and it does so silently. This app only ever asks - // for the video quality and the stabilization that the user selected, so a dropped feature - // means a setting the UI still displays is not in effect. Say so rather than letting the - // recording quietly disagree with the settings screen. - // - // What was asked for -- including which camera it was asked of -- is passed in rather than - // read back from a field: the callback is delivered asynchronously, so a field could already - // describe a later bind by the time this runs, and the message would then name settings (or - // dedup against a camera) that this result never involved. - private fun onFeaturesSelected( - boundLensFacing: Int, - requested: List, - qualityFeature: GroupableFeature?, - selected: Set, - ) { - // The full request-vs-result picture (including which stabilization feature, if any, - // survived) is only ever logged, never shown: the lead wants EIS left silently in its - // known state -- 4K keeps priority and stabilization is given up without a notice. - Log.i(TAG, "Requested $requested but got $selected") - - val qualityLabel = qualityFeature?.let { describeQualityFeature(it) } - // Only report a dropped quality that can be named: a message that can't say which - // quality it means would be worse than the log line above. - val droppedQuality = qualityLabel?.takeIf { qualityFeature !in selected } - - // A dropped quality is the only outcome worth a toast, and it is a genuine one: CameraX - // tries the quality on its own before it tries either stabilization, and the preflight in - // startCamera already gave up in-video snapshots wherever that would let the quality bind, - // so a quality reported dropped here is one this camera cannot record at in the minimal - // configuration either. Stabilization losses are deliberately not surfaced -- besides the - // lead's no-EIS-messaging wish, a "stabilization unsupported" message would misattribute - // the loss, because stabilization can be crowded out by the in-video snapshot stream - // rather than by the quality. - val message = droppedQuality?.let { - mActivity.getString(R.string.quality_unsupported, it) - } - - if (message == null) { - lastReportedDivergence.remove(boundLensFacing) - return - } - - if (message != lastReportedDivergence[boundLensFacing]) { - lastReportedDivergence[boundLensFacing] = message - mActivity.showMessage(message) - } - } - - private fun isLensFacingSupported(lensFacing: Int): Boolean { - var tCameraSelector = CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build() - - if (currentMode.extensionMode != ExtensionMode.NONE) { - extensionsManager?.let { em -> - if (!isExtensionUsable(tCameraSelector, lensFacing, currentMode.extensionMode)) - return false - - try { - tCameraSelector = em.getExtensionEnabledCameraSelector( - tCameraSelector, - currentMode.extensionMode - ) - } catch (e: IllegalArgumentException) { - return false - } - } - } - - return cameraProvider?.hasCamera(tCameraSelector) ?: false - } - - // Start the camera with latest hard configuration - @SuppressLint("RestrictedApi") - fun startCamera(forced: Boolean = false) { - if ((!forced && camera != null) || cameraProvider == null) return - - // Cancel any pending capture requests - mActivity.imageCapturer.cancelPendingCaptureRequest() - - mActivity.exposureBar.hidePanel() - slotCurrentMode() - - // Before the builder below reads it: the mode just slotted may store a different flash mode - // than the one that was bound, and the ImageCapture is configured once, at build time. - applyFlashMode(modeSettings.flashMode) - - val rotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val display = mActivity.display - display?.rotation ?: @Suppress("DEPRECATION") - mActivity.windowManager.defaultDisplay.rotation - } else { - // We don't really have any option here, but this initialization - // ensures that the app doesn't break later when the below - // deprecated option gets removed post Android R - @Suppress("DEPRECATION") - mActivity.windowManager.defaultDisplay.rotation - } - - if (mActivity.isDestroyed || mActivity.isFinishing) return - - // Test whether the current lens facing is supported by the current device - // If not then silently switch to the other lens facing - // (Snackbar/popup message can be shown before startCamera is called - // in specific cases of explicitly switching to another side or if - // the camera is expected) - if (!isLensFacingSupported(lensFacing)) { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK - } - } - - - cameraSelector = CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build() - - val builder = ImageCapture.Builder() - - // To use the last frame instead of showing a blank screen when - // the camera that is being currently used gets unbind - mActivity.updateLastFrame() - - // Unbind/close all other camera(s) [if any] - cameraProvider?.unbindAll() - - val extMode = currentMode.extensionMode - var appliedExtension: Pair? = null - if (extMode != ExtensionMode.NONE) { - val em = extensionsManager - if (em != null && isExtensionUsable(cameraSelector, lensFacing, extMode)) { - appliedExtension = lensFacing to extMode - cameraSelector = em.getExtensionEnabledCameraSelector(cameraSelector, extMode) - } else { - Log.e(TAG, "Mode $currentMode isn't available for this device") - } - } - - val useCasesList = arrayListOf() - - val aspectRatioStrategy = AspectRatioStrategy( - aspectRatio, AspectRatioStrategy.FALLBACK_RULE_AUTO - ) - - // CameraX 1.6.0's SessionConfig throws an IllegalArgumentException at construction time - // if any use case configures a groupable feature through a non-groupable API while a - // feature group is in use. Recorder.Builder.setQualitySelector() is such an API since - // 1.6.0 introduced GroupableFeatures.*_RECORDING, so when EIS is requested through - // GroupableFeature.PREVIEW_STABILIZATION (the only case where this app uses a feature - // group), the video quality has to be requested through the feature group as well. - // - // The validation triggers on setQualitySelector() having been called at all, not on the - // quality it was given, so this flag -- not the resolved feature below -- is what decides - // whether that setter may be used. videoQualityAsGroupableFeature() can legitimately fail - // to map the current quality, and falling back to setQualitySelector() in that case would - // reintroduce the very exception this works around. - // - // Both halves of canApplyVideoStabilization() gate the group. The platform has to be able - // to verify feature combinations: where it cannot, the resolver would conflate "could not - // check" with "unsupported", quietly discard the stored video quality and produce - // untruthful notices. Cameras behind that gate use the pre-1.6 non-groupable setters - // below instead, which are legal exactly because no feature group is in use then. And the - // group exists solely to negotiate stabilization against the quality, so a camera that - // supports no stabilization at all has nothing to negotiate: it takes the plain fallback - // path directly, which produces the identical output without a needless feature-group - // round. - val usesFeatureGroup = isVideoMode && enableEIS && canApplyVideoStabilization() - val videoQualityFeature: GroupableFeature? = when { - usesFeatureGroup -> videoQualityAsGroupableFeature() - else -> null - } - - val captureMode = when { - waitForFocusLock -> ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY - enableZsl -> ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG - else -> ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY - } - - if (isQRMode) { - val analyzer = QRAnalyzer(mActivity) - val strategy = ResolutionStrategy( - Size(960, 960), - ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER - ) - val mIAnalyzer = ImageAnalysis.Builder() - .setResolutionSelector( - ResolutionSelector.Builder().setResolutionStrategy(strategy).build() - ) - .setOutputImageRotationEnabled(true) - .build() - qrAnalyzer = analyzer - mActivity.startFocusTimer() - iAnalyzer = mIAnalyzer - mIAnalyzer.setAnalyzer(cameraExecutor, analyzer) - cameraSelector = CameraSelector.Builder() - .requireLensFacing( - if (isLensFacingSupported(CameraSelector.LENS_FACING_BACK)) { - CameraSelector.LENS_FACING_BACK - } else { - mActivity.showMessage(R.string.qr_rear_camera_unavailable) - CameraSelector.LENS_FACING_FRONT - } - ) - .build() - useCasesList.add(mIAnalyzer) - - } else { - if (isVideoMode) { - - mActivity.micOffIcon.visibility = when { - includeAudio -> View.GONE - else -> View.VISIBLE - } - - val recorderBuilder = Recorder.Builder() - - // camera-video 1.6 writes mp4 through the media3 muxer, which cannot keep up with - // 2160p on a Tensor device: the audio queue overflows a few seconds in and stop - // then has to drain everything the muxer is behind by. The platform muxer, which - // is what every release up to 1.5 used, keeps up. Both live in an internal - // package, so this has to be re-checked on every camera-video upgrade. - recorderBuilder.setMuxerFactory { MediaMuxerImpl() } - - if (!usesFeatureGroup) { - recorderBuilder.setQualitySelector(QualitySelector.from(videoQuality)) - } - - val videoCaptureBuilder = VideoCapture.Builder(recorderBuilder.build()) - - // On cameras where the feature group is not used (see canApplyVideoStabilization) - // EIS is deliberately left off. The pre-1.6 stabilization setters cannot be applied - // on top of the recorder's higher qualities -- UHD in particular is in none of the - // stabilization-guaranteed configurations -- so requesting them would either kill - // the preview or force the quality down. We keep the selected quality (4K by - // default) and simply do not stabilize. This is the app's long-standing behavior - // (EIS regressed here when it moved off the Camera2 API); the toggle is hidden - // rather than left inert on these cameras, and implementing EIS for them -- the - // pre-1.6 setters, restricted to the qualities that permit them -- is a separate - // change that needs a device the feature group cannot serve to test on. - - if (mActivity.camConfig.saveVideoAsPreviewed) { - videoCaptureBuilder.setMirrorMode(MirrorMode.MIRROR_MODE_ON_FRONT_ONLY) - } - - videoCapture = videoCaptureBuilder.build() - - useCasesList.add(videoCapture!!) - } - - if (!mActivity.requiresVideoModeOnly) { - imageCapture = builder.let { - it.setCaptureMode(captureMode) - - - it.setTargetRotation( - imageCapture?.targetRotation - ?: rotation - ) - - val resolutionSelectorBuilder = ResolutionSelector.Builder() - .setAspectRatioStrategy(aspectRatioStrategy) - - if (selectHighestResolution) { - resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) - } - - it.setResolutionSelector(resolutionSelectorBuilder.build()) - - it.setFlashMode(flashMode) - - it.setJpegQuality(photoQuality) - - it.build() - } - - useCasesList.add(imageCapture!!) - } - } - - val previewBuilder = Preview.Builder() - .setTargetRotation( - preview?.targetRotation - ?: rotation - ) - .setResolutionSelector( - ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build() - ) - - // Pixels and potentially other devices enable EIS by default, which reduces the field of - // view and image quality for image capture if it's not explicitly disabled. - // - // setPreviewStabilizationEnabled() is one of the non-groupable setters that SessionConfig - // rejects outright once a feature group is in use, so it must not be called at all -- - // with either value -- when stabilization is being requested through the feature group - // below. On every other path it is explicitly disabled: EIS is not applied off the feature - // group (see the VideoCapture builder above), and leaving it unset would let a device - // default preview stabilization cost photo capture its field of view. - when { - usesFeatureGroup -> {} - - else -> previewBuilder.setPreviewStabilizationEnabled(false) - } - - preview = previewBuilder.build().also { - useCasesList.add(it) - it.surfaceProvider = mActivity.previewView.surfaceProvider - } - - mActivity.forceUpdateOrientationSensor() - - // The list ordering encodes priority (highest priority first): CameraX walks the subsets - // of this list in order and binds the first one the camera supports, so trailing features - // are the ones given up first. - // - // The video quality leads because it is an explicit, deliberate choice from a spinner, - // whereas stabilization is a toggle that defaults to on and that most users never touch; - // an explicit choice should not lose to a default. This inverts the CameraX 1.5.x - // behaviour, where asking for 2160p on a Pixel silently recorded at 1080p because - // stabilization won. Whatever is given up is now reported by onFeaturesSelected(). - // - // Both stabilization features are listed because the EIS toggle is offered whenever - // either kind is supported (see canApplyVideoStabilization). They share one feature - // type, so CameraX never selects both and skips the subsets containing the pair; the - // effective order is quality+preview-stabilization, quality+video-stabilization, quality - // alone, then the same three without the quality. Preview stabilization is preferred - // because it stabilizes the preview and the recording alike, making the framing that is - // shown the framing that is recorded, while video stabilization only stabilizes the file. - // - // If the quality feature is dropped the quality follows Recorder's default quality - // selector (FHD, HD, SD in that order). - val preferredFeatures = arrayListOf() - - if (usesFeatureGroup) { - videoQualityFeature?.let { preferredFeatures.add(it) } - preferredFeatures.add(GroupableFeature.PREVIEW_STABILIZATION) - preferredFeatures.add(GroupableFeatures.VIDEO_STABILIZATION) - } - - // Not every camera can run video, photo and preview at once. Ask before binding rather - // than binding and retrying without the photo use case when it throws: an - // IllegalArgumentException from bindToLifecycle() carries no indication of which - // constraint was violated, so the retry could not tell "this camera can't do video plus - // photo" apart from any other misconfiguration, and would answer both by silently - // dropping in-video snapshots. A genuine bug then looked like a missing feature. This - // asks the specific question, and leaves unexpected exceptions to surface as failures. - // - // Asking costs 80-140 ms, because the camera service resolves the whole feature group to - // answer it, so the verdict is cached (snapshotDropReason) and every entry into video mode - // after the first is free. - val snapshotUseCase = imageCapture - if (isVideoMode && snapshotUseCase != null) { - val probeKey = SnapshotProbeKey( - lensFacing, videoQuality, usesFeatureGroup, captureMode, selectHighestResolution - ) - - if (!snapshotDropReason.containsKey(probeKey)) { - snapshotProbeCount++ - - val cameraInfo = try { - cameraProvider?.getCameraInfo(cameraSelector) - } catch (exception: IllegalArgumentException) { - Log.e(TAG, "Failed to query camera info", exception) - mActivity.showMessage(mActivity.getString(R.string.bind_failure)) - return - } - - fun isSupported(useCases: List, features: Set) = - cameraInfo?.isSessionConfigSupported( - SessionConfig(useCases = useCases, requiredFeatureGroup = features) - ) == true - - // When a quality feature is about to be requested, the probe has to require that - // quality too: a camera can be able to run the three plain streams yet not at the - // chosen quality, and a probe without it would keep the snapshot use case and leave - // the conflict to the feature-group resolver -- which resolves it by dropping the - // *quality*, with a notice blaming the camera for a quality it does support. The - // quality wins the conflict because it is an explicit choice from the settings while - // in-video snapshots are an implicit capability (the same reasoning as the preferred - // feature ordering above), and giving up the snapshots is already the established - // answer when they can't be bound at all. The second isSupported(useCasesList, - // emptySet()) arm keeps the snapshots when the quality is unreachable even without - // them: dropping them would buy nothing, and the resolver's "unsupported quality" - // notice is genuinely true then. - snapshotDropReason[probeKey] = when { - videoQualityFeature == null -> when { - isSupported(useCasesList, emptySet()) -> null - else -> "Video, photo and preview can't be bound together on this camera" - } - - isSupported(useCasesList, setOf(videoQualityFeature)) -> null - isSupported(useCasesList - snapshotUseCase, setOf(videoQualityFeature)) -> { - "This camera can't record at the selected video quality with in-video " + - "snapshots enabled" - } - - isSupported(useCasesList, emptySet()) -> null - else -> "Video, photo and preview can't be bound together on this camera" - } - } - - val dropReason = snapshotDropReason[probeKey] - if (dropReason != null) { - Log.i(TAG, "$dropReason; disabling snapshots while recording") - useCasesList.remove(snapshotUseCase) - imageCapture = null - } - } - - try { - val sessionConfig = SessionConfig( - useCases = useCasesList, - preferredFeatureGroup = preferredFeatures - ) - - if (preferredFeatures.isNotEmpty()) { - val requested = preferredFeatures.toList() - val boundLensFacing = lensFacing - sessionConfig.setFeatureSelectionListener( - ContextCompat.getMainExecutor(mActivity) - ) { selected -> - onFeaturesSelected(boundLensFacing, requested, videoQualityFeature, selected) - } - } - - camera = cameraProvider!!.bindToLifecycle( - mActivity, cameraSelector, - sessionConfig - ) - } catch (exception: RuntimeException) { - // A vendor extension can still fail to initialize at bind time even though the - // pre-flight probe in isExtensionUsable() passed. When one was applied, record the - // failure so the mode stops being offered, rather than letting the exception kill the - // process or -- equally bad -- retrying the same doomed bind on every resume. - val key = appliedExtension - if (key == null) { - // No extension in play: an IllegalArgumentException is a plain unsupported - // configuration (reported and swallowed); anything else is a real bug that must - // stay visible. - if (exception is IllegalArgumentException) { - Log.e(TAG, "Failed to bind use cases", exception) - mActivity.showMessage(mActivity.getString(R.string.bind_failure)) - return - } - throw exception - } - - // With an extension applied, only the vendor's known-permanent signatures mean "this - // mode is unusable here": UnsupportedOperationException (a vendor extender that - // advertised the mode and then threw from its own init -- Pixels: "Framework size list - // map not supported in pixel path") and IllegalArgumentException (an extension bind - // always uses the same fixed pair of use cases, so an invalid configuration is as - // permanent as any other vendor failure). Anything else is a real bug and is rethrown - // rather than hidden behind a silent mode switch. - if (exception !is UnsupportedOperationException && exception !is IllegalArgumentException) { - throw exception - } - - Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) - extensionUsability[key] = false - mActivity.showMessage(mActivity.getString(R.string.extension_mode_unavailable)) - - // The bind never completed: currentMode still names the mode that was just disabled - // and nothing is rendering into the preview. Refreshing the tabs alone would only - // *visually* select another tab, leaving a frozen preview behind a lying tab bar. - // Switch for real -- switchMode() rebinds, moves the highlight and refreshes the tabs. - // Recursion stops because the default mode uses no extension. - switchMode(DEFAULT_CAMERA_MODE) - return - } - - loadTabs() - - // Every bind hands out a fresh LiveData in extension modes, and the old observer would - // otherwise stay attached: after a handful of mode switches a single zoom step redrew the - // thumb once per bind that had ever happened. - zoomStateSource?.removeObserver(zoomStateObserver) - zoomStateSource = null - zoomState = null - // Reading the new one is what costs ~100 ms behind an extension, and nothing before the - // next message needs it: the bar below draws a freshly bound camera's 1.0x either way, and - // every other reader is a gesture. - handler.removeCallbacks(attachZoomState) - handler.post(attachZoomState) - - mActivity.zoomBar.updateThumb(false) - - camera?.cameraInfo?.exposureState?.let { mActivity.exposureBar.setExposureConfig(it) } - - mActivity.settingsDialog.torchToggle.isChecked = false - - // Focus camera on touch/tap - mActivity.previewView.setOnTouchListener(mActivity) - camera?.cameraInfo?.let { mActivity.previewView.applyPreviewRatio(aspectRatio, it) } - - if (isInPhotoMode) { - mActivity.sensorNotifier?.forceUpdateGyro() - } else { - mActivity.gCircleFrame.visibility = View.GONE - } - } - - fun snapPreview() { - - if (selfIlluminate) { - - val animation: Animation = AlphaAnimation(0f, 0.8f) - animation.duration = PREVIEW_SL_OVERLAY_DUR - animation.interpolator = LinearInterpolator() - animation.fillAfter = true - - mActivity.mainOverlay.setImageResource(android.R.color.white) - - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationStart(p0: Animation?) { - mActivity.mainOverlay.visibility = View.VISIBLE - } - - override fun onAnimationEnd(p0: Animation?) {} - - override fun onAnimationRepeat(p0: Animation?) {} - - } - ) - - mActivity.mainOverlay.startAnimation(animation) - - } else { - - val animation: Animation = AlphaAnimation(1f, 0f) - animation.duration = PREVIEW_SNAP_DURATION - animation.interpolator = LinearInterpolator() - animation.repeatMode = Animation.REVERSE - - mActivity.mainOverlay.setImageResource(android.R.color.black) - - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationStart(p0: Animation?) { - mActivity.mainOverlay.visibility = View.VISIBLE - } - - override fun onAnimationEnd(p0: Animation?) { - mActivity.mainOverlay.visibility = View.INVISIBLE - mActivity.mainOverlay.setImageResource(android.R.color.transparent) - } - - override fun onAnimationRepeat(p0: Animation?) {} - - } - ) - - mActivity.mainOverlay.startAnimation(animation) - } - } - - // probeOnMiss is false because tab refreshes must never pay for a vendor probe on the main - // thread (see loadTabs); an unprobed mode is left out of the tabs for now, exactly like a - // transiently-failed probe always was, and comes back on the refresh that follows its probe. - private fun availableModes(): Set { - return CameraMode.entries.filter { - when (it) { - CameraMode.CAMERA, CameraMode.VIDEO -> true - CameraMode.QR_SCAN -> mActivity !is SecureMainActivity - else -> { - check(it.extensionMode != ExtensionMode.NONE) - isExtensionUsable( - FRONT_CAMERA_SELECTOR, CameraSelector.LENS_FACING_FRONT, - it.extensionMode, probeOnMiss = false - ) || isExtensionUsable( - REAR_CAMERA_SELECTOR, CameraSelector.LENS_FACING_BACK, - it.extensionMode, probeOnMiss = false - ) - } - } - }.toSet() - } - - // The (selector, cache key) pairs availableModes() needs a verdict for that only a vendor - // probe can answer. Deliberately a pure cache check: even asking whether a mode is - // advertised costs a vendor proxy round trip (see probeExtension), so the probe round has - // to answer that too. - private fun unprobedExtensions(): List>> { - if (extensionsManager == null || cameraProvider == null) return emptyList() - - val result = arrayListOf>>() - for (mode in CameraMode.entries) { - if (mode.extensionMode == ExtensionMode.NONE) continue - for ((selector, lensFacing) in arrayOf( - FRONT_CAMERA_SELECTOR to CameraSelector.LENS_FACING_FRONT, - REAR_CAMERA_SELECTOR to CameraSelector.LENS_FACING_BACK, - )) { - val key = lensFacing to mode.extensionMode - if (extensionUsability[key] == null) { - result.add(selector to key) - } - } - } - return result - } - - private var extensionProbesInFlight = false - - private fun loadTabs() { - if (!mActivity.shouldShowCameraModeTabs()) { - return - } - - // Refreshing the tabs must not run extension probes on the calling (main) thread: the - // first refresh after process start needs one vendor-extender init round trip over - // binder per advertised mode per lens, which measures at over half a second of blocked - // main thread -- more than a hundred dropped frames -- during startup on a Pixel 7 Pro. - // The probes run on their own short-lived thread instead (not cameraExecutor, which the - // QR analyzer may be draining) and the tabs are built once every verdict is in, so the - // tab bar still appears exactly once, fully formed, at the same time it used to; until - // then swipes and taps resolve to no tab and the app simply stays in the current mode. - // Later refreshes find the cache warm and rebuild synchronously, exactly as before. - val pending = unprobedExtensions() - if (pending.isEmpty()) { - buildTabs() - return - } - - if (extensionProbesInFlight) return - val provider = cameraProvider ?: return - val em = extensionsManager ?: return - extensionProbesInFlight = true - - thread { - val verdicts = HashMap, Boolean?>() - for ((selector, key) in pending) { - verdicts[key] = probeExtension(provider, em, selector, key.second) - } - - ContextCompat.getMainExecutor(mActivity).execute { - extensionProbesInFlight = false - if (mActivity.isDestroyed || mActivity.isFinishing) return@execute - - if (probedCameraProvider !== provider) { - // The camera stack was reinitialized while probing: these verdicts describe - // vendor state that no longer exists (the same reasoning as the cache clear - // in initializeCamera). Any refresh that ran for the new provider found this - // round still in flight and skipped scheduling, so start over for it. - loadTabs() - return@execute - } - - for ((key, verdict) in verdicts) { - // Only fill in keys that are still unprobed. A bind failure that ran while this - // round was in flight may have blacklisted the mode (extensionUsability[key] = - // false); that verdict is fresher than this probe's and must not be overwritten - // -- otherwise a mode that just failed to bind would be offered again. - if (verdict != null && extensionUsability[key] == null) { - extensionUsability[key] = verdict - } - } - buildTabs() - } - } - } - - @StringRes - private fun tabLabel(mode: CameraMode): Int { - return when (mode) { - CameraMode.QR_SCAN -> R.string.qr_scan_mode - CameraMode.AUTO -> R.string.auto_mode - CameraMode.FACE_RETOUCH -> R.string.face_retouch_mode - CameraMode.PORTRAIT -> R.string.portrait_mode - CameraMode.NIGHT -> R.string.night_mode - CameraMode.HDR -> R.string.hdr_mode - CameraMode.CAMERA -> R.string.camera - CameraMode.VIDEO -> R.string.video - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun buildTabs() { - val tabLayout = mActivity.tabLayout - val availableModes = availableModes() - - if (availableModes == tabLayout.getAllModes()) { - return - } - - Log.i(TAG, "Refreshing tabs...") - - tabLayout.removeAllTabs() - - availableModes.forEach { mode -> - tabLayout.newTab().let { tab -> - tab.setText(tabLabel(mode)) - - tab.view.setOnTouchListener { _, e -> - if (e.action == MotionEvent.ACTION_UP) { - mActivity.finalizeMode(tab) - } - false - } - tab.tag = mode - - // Highlight the mode the camera is really in, not the default one: the tabs are - // also rebuilt long after startup, once the extension probes report back. - tabLayout.addTab(tab, mode == currentMode) - } - } - } - - fun switchMode(mode: CameraMode) { - if (currentMode == mode) { - return - } - - currentMode = mode - - mActivity.cancelFocusTimer() - - isQRMode = mode == CameraMode.QR_SCAN - - isVideoMode = mode == CameraMode.VIDEO - - if (isQRMode) { - mActivity.qrOverlay.visibility = View.VISIBLE - mActivity.thirdOption.visibility = View.INVISIBLE - - if (scanAllCodes) { - mActivity.setFlipCameraIcon( - R.drawable.cancel, R.string.stop_scanning_all_formats - ) - mActivity.qrScanToggles.visibility = View.GONE - } else { - mActivity.setFlipCameraIcon( - R.drawable.auto, R.string.scan_all_formats - ) - mActivity.qrScanToggles.visibility = View.VISIBLE - } - - mActivity.cancelButtonView.visibility = View.INVISIBLE - - mActivity.captureButton.setBackgroundResource(android.R.color.transparent) - // Entering QR mode always leaves the torch off - mActivity.setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) - - mActivity.micOffIcon.visibility = View.GONE - } else { - mActivity.qrOverlay.visibility = View.INVISIBLE - mActivity.thirdOption.visibility = View.VISIBLE - mActivity.setFlipCameraIcon(R.drawable.flip_camera, R.string.flip_camera) - mActivity.cancelButtonView.visibility = View.VISIBLE - - mActivity.qrScanToggles.visibility = View.GONE - - mActivity.captureButton.setBackgroundResource(R.drawable.cbutton_bg) - - if (isVideoMode) { - mActivity.setCaptureButtonIcon(R.drawable.recording, R.string.start_recording) - } else { - mActivity.setCaptureButtonIcon(R.drawable.camera_shutter, R.string.capture) - mActivity.micOffIcon.visibility = View.GONE - } - } - - mActivity.updateSelfTimerBadge() - - startCamera(true) - - // A mode can change with no touch involved, so the strip follows the camera and not the - // other way round - currentMode, because an extension that fails to bind falls back to - // another mode from inside startCamera(). Left until after that rebind, which blocks the - // main thread for long enough to swallow the animation whole. - if (mActivity.shouldShowCameraModeTabs()) { - mActivity.tabLayout.getTabForMode(currentMode)?.let { tab -> - mActivity.tabLayout.goToTab(tab) - } - } - } - - fun showMoreOptionsForQR() { - val builder = MaterialAlertDialogBuilder(mActivity) - builder.setTitle(mActivity.resources.getString(R.string.more_options)) - - val optionNames = arrayListOf() - val optionValues = arrayListOf() - - for (format in BarcodeFormat.entries) { - - if (format in commonFormats) continue - - optionNames.add(format.name) - - optionValues.add(format.name in settings.enabledBarcodeFormats) - } - - builder.setMultiChoiceItems( - optionNames.toArray(arrayOf()), - optionValues.toBooleanArray() - ) { _, index, isChecked -> - optionValues[index] = isChecked - } - - // Add OK and Cancel buttons - builder.setPositiveButton(getString(R.string.ok)) { _, _ -> - - val allCommonFormatsDisabled = commonFormats.none { - allowedFormats.contains(it) - } - - // If all formats displayed outside the dialog are disabled (main QR scanner - // UI) - if (allCommonFormatsDisabled) { - val noOptionWasChecked = optionValues.none { it } - - // If no option is selected within the check box too (implying no barcode format - // is selected at all) - don't make apply the selction made by the user - if (noOptionWasChecked) { - mActivity.showMessage( - getString(R.string.no_barcode_selected) - ) - return@setPositiveButton - } - } - - for ((index, optionName) in optionNames.withIndex()) { - - val format = BarcodeFormat.valueOf(optionName) - - if (optionValues[index]) { - if (format !in allowedFormats) { - allowedFormats.add(format) - } - } else { - allowedFormats.remove(format) - } - } - - settings = runBlocking { - settingsRepository.update { current -> - optionNames.foldIndexed(current) { index, updated, optionName -> - updated.withBarcodeFormat( - formatName = optionName, - enabled = optionValues[index], - ) - } - } - } - - qrAnalyzer?.refreshHints() - } - - builder.setNegativeButton(R.string.cancel, null) - - // Create and show the alert dialog - val dialog = builder.create() - - dialog.setOnShowListener { - val button: Button = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - button.setOnClickListener { - - } - } - - dialog.showIgnoringShortEdgeMode() - } - - fun onStorageLocationNotFound() { - // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH - storageLocation = CapturedItemRepository.MEDIA_STORE_LOCATION - - val builder = MaterialAlertDialogBuilder(mActivity) - .setTitle(R.string.folder_not_found) - .setMessage(R.string.reverting_to_default_folder) - .setPositiveButton(R.string.ok, null) - .setNeutralButton(R.string.more_settings) { _, _ -> - MoreSettings.start(mActivity) - } - val alertDialog = builder.create() - alertDialog.setCancelable(false) - alertDialog.showIgnoringShortEdgeMode() - } -} diff --git a/app/src/main/java/app/grapheneos/camera/TunePlayer.kt b/app/src/main/java/app/grapheneos/camera/TunePlayer.kt index a6c901a8a..6cd16321a 100644 --- a/app/src/main/java/app/grapheneos/camera/TunePlayer.kt +++ b/app/src/main/java/app/grapheneos/camera/TunePlayer.kt @@ -40,7 +40,7 @@ open class TunePlayer(val context: MainActivity) { } private fun shouldNotPlayTune(): Boolean { - return !context.camConfig.enableCameraSounds + return !context.viewfinder.enableCameraSounds } fun playShutterSound() { diff --git a/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt b/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt index 06820cfd7..b85a28f37 100644 --- a/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt +++ b/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt @@ -30,20 +30,18 @@ class QRAnalyzer(private val mActivity: MainActivity) : Analyzer { } fun refreshHints() { - val camConfig = mActivity.camConfig + val allowedFormats = mActivity.barcodeFormats.enabled val supportedHints: MutableMap = EnumMap( DecodeHintType::class.java ) - Log.i(TAG, "allowedFormats: ${camConfig.allowedFormats}") + Log.i(TAG, "allowedFormats: $allowedFormats") - supportedHints[DecodeHintType.POSSIBLE_FORMATS] = - if (camConfig.scanAllCodes) { - BarcodeFormat.values().asList() - } else { - camConfig.allowedFormats - } + supportedHints[DecodeHintType.POSSIBLE_FORMATS] = when { + mActivity.viewfinder.scanAllCodes -> BarcodeFormat.entries + else -> allowedFormats + } reader.setHints(supportedHints) } diff --git a/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt index 17d947264..404a3bb3a 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt @@ -28,7 +28,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder private const val imageFileFormat = ".jpg" class ImageCapturer(val mActivity: MainActivity) { - val camConfig = mActivity.camConfig + val viewfinder = mActivity.viewfinder + + private val session = mActivity.session val isTakingPicture: Boolean get() = currentImageSaver != null @@ -59,11 +61,11 @@ class ImageCapturer(val mActivity: MainActivity) { @SuppressLint("RestrictedApi") fun takePicture() { - if (camConfig.camera == null) { + if (session.camera == null) { return } - if (!camConfig.canTakePicture) { + if (!viewfinder.canTakePicture) { mActivity.showMessage(R.string.unsupported_taking_picture_while_recording) return } @@ -73,10 +75,11 @@ class ImageCapturer(val mActivity: MainActivity) { } val imageMetadata = ImageCapture.Metadata() - imageMetadata.isReversedHorizontal = camConfig.lensFacing == CameraSelector.LENS_FACING_FRONT - && camConfig.saveImageAsPreviewed + imageMetadata.isReversedHorizontal = + session.lensFacing == CameraSelector.LENS_FACING_FRONT + && viewfinder.saveImageAsPreviewed - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { val location = (mActivity.applicationContext as App).getLocation() if (location == null) { mActivity.showMessage(R.string.location_unavailable) @@ -87,16 +90,16 @@ class ImageCapturer(val mActivity: MainActivity) { val preview = mActivity.imagePreview - val imageCapture = camConfig.imageCapture!! + val imageCapture = session.imageCapture!! val imageSaver = ImageSaver( this, mActivity.applicationContext, imageCapture.jpegQuality, - camConfig.storageLocation, + mActivity.capturedItemSession.storageLocation, imageFileFormat, imageMetadata, - camConfig.removeExifAfterCapture, + viewfinder.removeExifAfterCapture, targetThumbnailWidth = preview.width, targetThumbnailHeight = preview.height, ) @@ -120,11 +123,11 @@ class ImageCapturer(val mActivity: MainActivity) { unfadeCaptureButton() currentImageSaver = null - camConfig.mPlayer.playShutterSound() - camConfig.snapPreview() + viewfinder.mPlayer?.playShutterSound() + viewfinder.snapPreview() mActivity.previewLoader.visibility = View.VISIBLE - if (camConfig.selfIlluminate) { + if (viewfinder.selfIlluminate) { val animation: Animation = AlphaAnimation(0.8f, 0f) animation.duration = 200 @@ -167,7 +170,7 @@ class ImageCapturer(val mActivity: MainActivity) { } fun onImageSaverSuccess(item: CapturedItem) { - camConfig.updateLastCapturedItem(item) + mActivity.capturedItemSession.recordCapturedItem(item) if (mActivity is SecureMainActivity) { mActivity.capturedItems.add(item) @@ -175,7 +178,7 @@ class ImageCapturer(val mActivity: MainActivity) { } fun onStorageLocationNotFound() { - camConfig.onStorageLocationNotFound() + viewfinder.onStorageLocationNotFound() } fun onImageSaverError(exception: ImageSaverException, skipErrorDialog: Boolean) { diff --git a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt index d8f7ce5b1..f0f7cb30a 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt @@ -19,13 +19,13 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.internal.compat.workaround.ExifRotationAvailability import androidx.camera.core.internal.utils.ImageUtil import androidxc.camera.core.impl.utils.Exif -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.IMAGE_NAME_PREFIX import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.capturer.ImageSaverException.Place import app.grapheneos.camera.clearExif import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.media.store.imageCollectionUri import app.grapheneos.camera.fixExif import app.grapheneos.camera.util.ImageResizer import app.grapheneos.camera.util.executeIfAlive @@ -307,7 +307,7 @@ class ImageSaver( put(MediaStore.MediaColumns.IS_PENDING, 1) } - return contentResolver.insert(CamConfig.imageCollectionUri, cv) + return contentResolver.insert(imageCollectionUri, cv) } else { try { val treeUri = Uri.parse(storageLocation) diff --git a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt index b919ad3e9..edce6e1d9 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -27,12 +27,12 @@ import androidx.camera.video.Recorder import androidx.camera.video.Recording import androidx.camera.video.VideoRecordEvent import app.grapheneos.camera.App -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R import app.grapheneos.camera.VIDEO_NAME_PREFIX import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.media.store.videoCollectionUri import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity @@ -45,7 +45,9 @@ import java.util.Locale class VideoCapturer(private val mActivity: MainActivity) { - val camConfig = mActivity.camConfig + val viewfinder = mActivity.viewfinder + + private val session = mActivity.session var isRecording = false private set @@ -105,7 +107,7 @@ class VideoCapturer(private val mActivity: MainActivity) { uri = ctx.outputUri shouldAddToGallery = false } else { - val storageLocation = camConfig.storageLocation + val storageLocation = mActivity.capturedItemSession.storageLocation if (storageLocation == CapturedItemRepository.MEDIA_STORE_LOCATION) { val contentValues = ContentValues().apply { @@ -114,7 +116,7 @@ class VideoCapturer(private val mActivity: MainActivity) { put(MediaColumns.RELATIVE_PATH, DEFAULT_MEDIA_STORE_CAPTURE_PATH) put(MediaColumns.IS_PENDING, 1) } - uri = contentResolver.insert(CamConfig.videoCollectionUri, contentValues) + uri = contentResolver.insert(videoCollectionUri, contentValues) isPendingMediaStoreUri = true } else { val treeUri = Uri.parse(storageLocation) @@ -129,7 +131,7 @@ class VideoCapturer(private val mActivity: MainActivity) { } var location: Location? = null - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { location = (mActivity.applicationContext as App).getLocation() if (location == null) { mActivity.showMessage(R.string.location_unavailable) @@ -146,8 +148,8 @@ class VideoCapturer(private val mActivity: MainActivity) { } fun startRecording() { - if (camConfig.camera == null) return - val recorder = camConfig.videoCapture?.output ?: return + if (session.camera == null) return + val recorder = session.videoCapture?.output ?: return if (isRecording) return isRecording = true @@ -173,7 +175,7 @@ class VideoCapturer(private val mActivity: MainActivity) { } catch (exception: Exception) { val foreignUri = ctx is VideoCaptureActivity && ctx.isOutputUriAvailable() if (!foreignUri) { - camConfig.onStorageLocationNotFound() + viewfinder.onStorageLocationNotFound() } ctx.showMessage(R.string.unable_to_access_output_file) isRecording = false @@ -203,9 +205,9 @@ class VideoCapturer(private val mActivity: MainActivity) { afterRecordingStops() } - camConfig.mPlayer.playVRStartSound(handler) { + val onStartSoundPlayed = onStartSoundPlayed@{ if (consumed) { - return@playVRStartSound + return@onStartSoundPlayed } consumed = true @@ -224,7 +226,7 @@ class VideoCapturer(private val mActivity: MainActivity) { if (event is VideoRecordEvent.Finalize) { afterRecordingStops() - camConfig.mPlayer.playVRStopSound() + viewfinder.mPlayer?.playVRStopSound() if (event.hasError()) { when (event.error) { @@ -265,7 +267,7 @@ class VideoCapturer(private val mActivity: MainActivity) { if (recordingCtx.shouldAddToGallery) { val item = CapturedItem(ITEM_TYPE_VIDEO, dateString, uri) - camConfig.updateLastCapturedItem(item) + mActivity.capturedItemSession.recordCapturedItem(item) ctx.updateThumbnail() @@ -296,6 +298,11 @@ class VideoCapturer(private val mActivity: MainActivity) { e.printStackTrace() } } + + when (val player = viewfinder.mPlayer) { + null -> onStartSoundPlayed() + else -> player.playVRStartSound(handler, onStartSoundPlayed) + } } private val dp16 = 16 * mActivity.resources.displayMetrics.density @@ -369,7 +376,7 @@ class VideoCapturer(private val mActivity: MainActivity) { mActivity.settingsDialog.includeAudioToggle.isEnabled = false - if (camConfig.includeAudio) { + if (viewfinder.includeAudio) { mActivity.setMuteToggleState(muted = isMuted) mActivity.muteToggle.visibility = View.VISIBLE } @@ -423,14 +430,14 @@ class VideoCapturer(private val mActivity: MainActivity) { fun muteRecording() { if (!isRecording) return - check(camConfig.includeAudio) + check(viewfinder.includeAudio) isMuted = true recording?.mute(true) } fun unmuteRecording() { if (!isRecording) return - check(camConfig.includeAudio) + check(viewfinder.includeAudio) isMuted = false recording?.mute(false) } @@ -484,7 +491,7 @@ fun deleteStalePendingRecordings( try { // Pending rows are filtered out of every operation unless they are explicitly asked for. @Suppress("DEPRECATION") - val collection = MediaStore.setIncludePending(CamConfig.videoCollectionUri) + val collection = MediaStore.setIncludePending(videoCollectionUri) context.contentResolver.delete(collection, selection, args) } catch (e: Exception) { e.printStackTrace() diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt b/app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt new file mode 100644 index 000000000..2c9026698 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.data.camera.mapper + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import androidx.camera.video.Quality +import javax.inject.Inject + +interface VideoQualityFeatureMapper { + + fun map(quality: Quality): GroupableFeature? + + fun map(feature: GroupableFeature): Quality? +} + +internal class VideoQualityFeatureMapperImpl @Inject constructor() : VideoQualityFeatureMapper { + + override fun map(quality: Quality): GroupableFeature? { + return when (quality) { + Quality.UHD -> GroupableFeatures.UHD_RECORDING + Quality.FHD -> GroupableFeatures.FHD_RECORDING + Quality.HD -> GroupableFeatures.HD_RECORDING + Quality.SD -> GroupableFeatures.SD_RECORDING + else -> null + } + } + + override fun map(feature: GroupableFeature): Quality? { + return when (feature) { + GroupableFeatures.UHD_RECORDING -> Quality.UHD + GroupableFeatures.FHD_RECORDING -> Quality.FHD + GroupableFeatures.HD_RECORDING -> Quality.HD + GroupableFeatures.SD_RECORDING -> Quality.SD + else -> null + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/BindOutcome.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/BindOutcome.kt new file mode 100644 index 000000000..96d76dba5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/BindOutcome.kt @@ -0,0 +1,7 @@ +package app.grapheneos.camera.data.camera.model + +enum class BindOutcome { + BOUND, + FAILED, + EXTENSION_UNUSABLE, +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt new file mode 100644 index 000000000..eaab4779a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt @@ -0,0 +1,19 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.video.Quality + +data class CameraBindRequest( + val includesVideoCapture: Boolean, + val includesImageCapture: Boolean, + val aspectRatio: Int, + val imageCaptureTargetRotation: Int, + val previewTargetRotation: Int, + val flashMode: Int, + val photoQuality: Int, + val waitForFocusLock: Boolean, + val enableZsl: Boolean, + val selectHighestResolution: Boolean, + val videoQuality: Quality, + val mirrorVideoOnFrontCamera: Boolean, + val featureGroup: FeatureGroupRequest, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindSettings.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindSettings.kt new file mode 100644 index 000000000..e0059e56a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindSettings.kt @@ -0,0 +1,22 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.video.Quality +import app.grapheneos.camera.data.core.model.CameraMode + +data class CameraBindSettings( + val mode: CameraMode, + val isQrMode: Boolean, + val isVideoMode: Boolean, + val requiresVideoModeOnly: Boolean, + val qrLensFacing: Int?, + val rotation: Int, + val aspectRatio: Int, + val flashMode: Int, + val photoQuality: Int, + val videoQuality: Quality, + val waitForFocusLock: Boolean, + val enableZsl: Boolean, + val enableEis: Boolean, + val selectHighestResolution: Boolean, + val mirrorVideoOnFrontCamera: Boolean, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt new file mode 100644 index 000000000..e4bf6c1cf --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt @@ -0,0 +1,15 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture + +class CameraSessionPlan( + val videoCapture: VideoCapture?, + val imageCapture: ImageCapture?, + val preview: Preview, + val captureMode: ImageCaptureMode, + val preferredFeatures: List, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt new file mode 100644 index 000000000..07ec24b2b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt @@ -0,0 +1,24 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.core.CameraSelector + +data class ExtensionKey( + val lensFacing: Int, + val extensionMode: Int, +) { + companion object { + fun onBothLenses(extensionMode: Int): List { + return LENS_FACINGS.map { lensFacing -> + ExtensionKey( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + } + } + + private val LENS_FACINGS = listOf( + CameraSelector.LENS_FACING_FRONT, + CameraSelector.LENS_FACING_BACK, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt new file mode 100644 index 000000000..5d544edcd --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.core.featuregroup.GroupableFeature + +sealed interface FeatureGroupRequest { + + data object Unused : FeatureGroupRequest + + data class Requested( + val videoQualityFeature: GroupableFeature?, + ) : FeatureGroupRequest +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt new file mode 100644 index 000000000..b0fbc409a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt @@ -0,0 +1,7 @@ +package app.grapheneos.camera.data.camera.model + +enum class ImageCaptureMode { + MAXIMIZE_QUALITY, + ZERO_SHUTTER_LAG, + MINIMIZE_LATENCY, +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt new file mode 100644 index 000000000..3d876f16c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.data.camera.model + +sealed interface InVideoSnapshotSupport { + + data object Supported : InVideoSnapshotSupport + + enum class Unsupported( + val reason: String, + ) : InVideoSnapshotSupport { + STREAM_COMBINATION( + "Video, photo and preview can't be bound together on this camera", + ), + SELECTED_VIDEO_QUALITY( + "This camera can't record at the selected video quality with in-video " + + "snapshots enabled", + ), + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/model/SnapshotProbeKey.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/SnapshotProbeKey.kt new file mode 100644 index 000000000..dc4a97e1c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/SnapshotProbeKey.kt @@ -0,0 +1,15 @@ +package app.grapheneos.camera.data.camera.model + +import androidx.camera.video.Quality + +// Every setting that reaches one of the three probed SessionConfigs has to appear in the +// key. A setting added to the ImageCapture, Recorder or Preview builder without being added +// here would be answered from a verdict that predates it, which either takes in-video +// snapshots away for no reason or keeps them on a camera that cannot bind them. +data class SnapshotProbeKey( + val lensFacing: Int, + val videoQuality: Quality, + val usesFeatureGroup: Boolean, + val captureMode: ImageCaptureMode, + val selectHighestResolution: Boolean, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt new file mode 100644 index 000000000..d89d5bbec --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt @@ -0,0 +1,96 @@ +package app.grapheneos.camera.data.camera.repository + +import android.content.Context +import android.util.Log +import androidx.camera.extensions.ExtensionsManager +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import java.util.concurrent.ExecutionException +import javax.inject.Inject +import kotlin.concurrent.thread + +interface CameraProviderSource { + + fun acquireProvider( + context: Context, + onResult: (ProcessCameraProvider?) -> Unit, + ) + + fun acquireExtensionsManager( + context: Context, + provider: ProcessCameraProvider, + onResult: (ExtensionsManager?) -> Unit, + ) +} + +internal class CameraProviderSourceImpl @Inject constructor() : CameraProviderSource { + + override fun acquireProvider( + context: Context, + onResult: (ProcessCameraProvider?) -> Unit, + ) { + val providerFuture = ProcessCameraProvider.getInstance(context) + val deliverProvider = { + val provider = try { + providerFuture.get() + } catch (exception: ExecutionException) { + Log.e(TAG, "Camera provider initialization failed", exception) + null + } + + onResult(provider) + } + + providerFuture.addListener( + deliverProvider, + ContextCompat.getMainExecutor(context), + ) + } + + @Suppress("TooGenericExceptionCaught") + override fun acquireExtensionsManager( + context: Context, + provider: ProcessCameraProvider, + onResult: (ExtensionsManager?) -> Unit, + ) { + // Despite the name, getInstanceAsync() runs its one-time initialization body + // synchronously on the calling thread, and that body asks the vendor's extensions + // proxy service for each camera's advertised extensions until it finds one that has + // any -- measured at a handful of binder round trips on the main thread during + // startup on a Pixel 7 Pro. Call it from a short-lived thread instead; only the + // listener has to run on the main thread, for the field write and startCamera(). + thread { + try { + val extensionsManagerFuture = ExtensionsManager.getInstanceAsync(context, provider) + val deliverExtensionsManager = { + val extensionsManager = try { + extensionsManagerFuture.get() + } catch (exception: ExecutionException) { + Log.e(TAG, "Extensions manager future failed", exception) + null + } + + onResult(extensionsManager) + } + + extensionsManagerFuture.addListener( + deliverExtensionsManager, + ContextCompat.getMainExecutor(context), + ) + } catch (exception: Exception) { + // getInstanceAsync() runs its initialization synchronously (see above), so it + // -- or addListener -- can throw right here on this background thread, where an + // escaping exception would crash the process and, worse, leave the camera never + // started because the listener never runs. Recover exactly as the future-failure + // path does: report it and start the camera without extensions, on the main + // thread. + Log.e(TAG, "Extensions manager initialization failed", exception) + ContextCompat.getMainExecutor(context).execute { onResult(null) } + } + } + } + + private companion object { + const val TAG = "CameraProviderSource" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt new file mode 100644 index 000000000..37de4e863 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt @@ -0,0 +1,72 @@ +package app.grapheneos.camera.data.camera.repository + +import androidx.camera.extensions.ExtensionMode +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.core.model.CameraMode +import javax.inject.Inject + +interface ExtensionAvailabilityRepository { + + fun verdict(key: ExtensionKey): Boolean? + + fun record(key: ExtensionKey, usable: Boolean) + + fun recordProbeRound(verdicts: Map) + + // The cache keys availableModes() needs a verdict for that only a vendor probe can answer. + // Deliberately a pure cache check: even asking whether a mode is advertised costs a vendor + // proxy round trip (see probeExtension), so the probe round has to answer that too. + fun unprobed(): List + + fun clear() +} + +internal class ExtensionAvailabilityRepositoryImpl @Inject constructor() : + ExtensionAvailabilityRepository { + + // Whether a vendor extension is usable, keyed by lens facing and extension mode (see + // probeExtension). A verdict describes the device rather than any one activity and costs + // binder round trips to reach, so it is kept for the process: every lock screen launch used + // to re-probe what the session underneath it had already answered. + private val usability = HashMap() + + override fun verdict(key: ExtensionKey): Boolean? { + return usability[key] + } + + override fun record(key: ExtensionKey, usable: Boolean) { + usability[key] = usable + } + + override fun recordProbeRound(verdicts: Map) { + for ((key, verdict) in verdicts) { + // Only fill in keys that are still unprobed. A bind failure that ran while this round + // was in flight may have blacklisted the mode (record(key, usable = false)); that + // verdict is fresher than this probe's and must not be overwritten -- otherwise a mode + // that just failed to bind would be offered again. + if (verdict != null && usability[key] == null) { + usability[key] = verdict + } + } + } + + override fun unprobed(): List { + val result = arrayListOf() + + for (mode in CameraMode.entries) { + if (mode.extensionMode == ExtensionMode.NONE) continue + + for (key in ExtensionKey.onBothLenses(mode.extensionMode)) { + if (usability[key] == null) { + result.add(key) + } + } + } + + return result + } + + override fun clear() { + usability.clear() + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt new file mode 100644 index 000000000..ceff1ca1c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt @@ -0,0 +1,814 @@ +package app.grapheneos.camera.data.camera.session + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Size +import androidx.camera.core.Camera +import androidx.camera.core.CameraInfo +import androidx.camera.core.CameraSelector +import androidx.camera.core.DynamicRange +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.core.SessionConfig +import androidx.camera.core.TorchState +import androidx.camera.core.UseCase +import androidx.camera.core.ZoomState +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.extensions.ExtensionMode +import androidx.camera.extensions.ExtensionsManager +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.Quality +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.data.camera.model.BindOutcome +import app.grapheneos.camera.data.camera.model.CameraBindRequest +import app.grapheneos.camera.data.camera.model.CameraBindSettings +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.model.FeatureGroupRequest +import app.grapheneos.camera.data.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.data.camera.model.SnapshotProbeKey +import app.grapheneos.camera.data.camera.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.util.concurrent.Executors +import kotlin.concurrent.thread + +interface CameraSession { + + var listener: Listener? + var lensFacing: Int + + val cameraProvider: ProcessCameraProvider? + val camera: Camera? + val preview: Preview? + val imageCapture: ImageCapture? + val videoCapture: VideoCapture? + val iAnalyzer: ImageAnalysis? + val zoomState: ZoomState? + + val extensionsAvailable: Boolean + val isFlashAvailable: Boolean + val isZslSupported: Boolean + val isTorchOn: Boolean + + fun initialize(forced: Boolean, extensionMode: Int) + fun bind(settings: CameraBindSettings): BindOutcome + fun isLensFacingSupported(lensFacing: Int, extensionMode: Int): Boolean + fun selectLensFacing(lensFacing: Int) + fun probeUnknownExtensions(onRestart: () -> Unit, onSettled: () -> Unit) + fun canApplyVideoStabilization(): Boolean + fun toggleTorchState() + fun reattachZoomState() + fun refreshQrHints() + + interface Listener { + fun onZoomStateChanged() + fun onCameraProviderUnavailable() + fun onExtensionsUnavailable() + fun onProviderReady(forced: Boolean) + fun onFeaturesSelected( + boundLensFacing: Int, + requested: List, + qualityFeature: GroupableFeature?, + selected: Set, + ) + } +} + +@SuppressLint("UnsafeOptInUsageError") +internal class CameraSessionImpl @AssistedInject constructor( + @Assisted private val environment: CameraSessionEnvironment, + private val cameraProviderSource: CameraProviderSource, + private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, + private val featureCombinationSupport: FeatureCombinationSupport, + private val cameraSessionPlanFactory: CameraSessionPlanFactory, + private val videoQualityFeatureMapper: VideoQualityFeatureMapper, + private val inVideoSnapshotSupportResolver: InVideoSnapshotSupportResolver, + private val snapshotProbeCache: SnapshotProbeCache, +) : CameraSession { + + override var listener: CameraSession.Listener? = null + + override var camera: Camera? = null + + override var imageCapture: ImageCapture? = null + + override var preview: Preview? = null + + override var videoCapture: VideoCapture? = null + + override var iAnalyzer: ImageAnalysis? = null + + override var lensFacing = DEFAULT_LENS_FACING + + private var cameraSelector: CameraSelector = CameraSelector.Builder() + .requireLensFacing(DEFAULT_LENS_FACING) + .build() + + // Asking CameraInfo for the zoom state is cheap for a plain camera but costs ~100 ms once an + // extension is bound, because CameraX then queries the extension's zoom range through + // CameraExtensionCharacteristics, which enumerates every vendor key. Read this snapshot instead + // of the camera on any path that runs more than once per bind. It is null from the moment a + // bind starts until attachZoomState has run, which is where that query was moved to. + override var zoomState: ZoomState? = null + private set + + private var zoomStateSource: LiveData? = null + + private val zoomStateObserver = Observer { + zoomState = it + if (it.linearZoom != 0f || it.zoomRatio != 1f) { + listener?.onZoomStateChanged() + } + } + + private val handler = Handler(Looper.getMainLooper()) + + private val attachZoomState = Runnable { + if (!environment.isSessionActive) return@Runnable + + zoomStateSource = camera?.cameraInfo?.zoomState?.also { + it.observe(environment.sessionLifecycleOwner, zoomStateObserver) + } + + zoomState = zoomStateSource?.value + } + + override var cameraProvider: ProcessCameraProvider? = null + + private var extensionsManager: ExtensionsManager? = null + + private var extensionProbesInFlight = false + + private var qrAnalyzer: QRAnalyzer? = null + + private val cameraExecutor by lazy { + Executors.newSingleThreadExecutor() + } + + override val extensionsAvailable: Boolean + get() { + return extensionsManager != null && cameraProvider != null + } + + override val isFlashAvailable: Boolean + get() { + return camera?.cameraInfo?.hasFlashUnit() ?: false + } + + override val isZslSupported: Boolean + get() { + return camera?.cameraInfo?.isZslSupported ?: false + } + + override var isTorchOn: Boolean = false + get() { + return camera?.cameraInfo?.torchState?.value == TorchState.ON + } + set(value) { + field = when { + isFlashAvailable -> { + camera?.cameraControl?.enableTorch(value) + value + } + + else -> false + } + } + + override fun refreshQrHints() { + qrAnalyzer?.refreshHints() + } + + override fun selectLensFacing(lensFacing: Int) { + cameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + } + + // Every bind hands out a fresh LiveData in extension modes, and the old observer would + // otherwise stay attached: after a handful of mode switches a single zoom step redrew the + // thumb once per bind that had ever happened. + override fun reattachZoomState() { + zoomStateSource?.removeObserver(zoomStateObserver) + zoomStateSource = null + zoomState = null + // Reading the new one is what costs ~100 ms behind an extension, and nothing before the + // next message needs it: the bar below draws a freshly bound camera's 1.0x either way, and + // every other reader is a gesture. + handler.removeCallbacks(attachZoomState) + handler.post(attachZoomState) + } + + // Whether the EIS toggle has anything to act on. Stabilization is only ever requested through + // the feature group (see startCamera), which in turn needs the platform to be able to verify + // feature combinations, so a camera that can stabilize is not on its own enough: where the + // group cannot be used nothing applies EIS, and offering the toggle there is offering a + // control that does nothing. + override fun canApplyVideoStabilization(): Boolean { + return canVerifyFeatureCombinations() && isVideoStabilizationSupported() + } + + private fun isVideoStabilizationSupported(): Boolean { + // The toggle asks for both kinds of stabilization (see the preferred feature group in + // startCamera), preferring the preview kind and falling back to the recording-only kind, + // so it is meaningful whenever either one is available. Testing only the recorder + // capability both hid the toggle on cameras that can stabilize the preview but not the + // recording, and offered it on cameras where only the recorder can stabilize - where + // the bind used to ask exclusively for preview stabilization, leaving the toggle with + // nothing to do. + return isPreviewStabilizationSupported() || isRecorderStabilizationSupported() + } + + private fun isPreviewStabilizationSupported(): Boolean { + return Preview.getPreviewCapabilities(getCurrentCameraInfo()).isStabilizationSupported + } + + private fun isRecorderStabilizationSupported(): Boolean { + return Recorder.getVideoCapabilities(getCurrentCameraInfo()).isStabilizationSupported + } + + override fun toggleTorchState() { + isTorchOn = !isTorchOn + } + + private fun getCurrentCameraInfo(): CameraInfo { + val provider = requireNotNull(cameraProvider) { + "Camera provider is not ready yet" + } + + return provider.getCameraInfo(cameraSelector) + } + + override fun initialize(forced: Boolean, extensionMode: Int) { + if (cameraProvider != null) { + listener?.onProviderReady(forced) + return + } + + cameraProviderSource.acquireProvider(environment.sessionContext) { provider -> + when (provider) { + null -> listener?.onCameraProviderUnavailable() + else -> onCameraProviderReady(provider, forced, extensionMode) + } + } + } + + private fun onCameraProviderReady( + provider: ProcessCameraProvider, + forced: Boolean, + extensionMode: Int, + ) { + if (!snapshotProbeCache.isProbedThrough(provider)) { + // A different provider instance means the camera stack was reinitialized: + // extension verdicts probed through the previous instance (including bind-time + // blacklists, see startCamera) describe vendor state that no longer exists. + extensionAvailabilityRepository.clear() + snapshotProbeCache.adopt(provider) + } + cameraProvider = provider + + // Manually switch to the other lens facing (if the default lens facing isn't + // supported for the current device) + val isDefaultLensSupported = isLensFacingSupported( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + + if (!isDefaultLensSupported) { + lensFacing = when (lensFacing) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK + } + } + + cameraProviderSource.acquireExtensionsManager( + environment.sessionContext, + provider, + ) { manager -> + when (manager) { + null -> listener?.onExtensionsUnavailable() + else -> extensionsManager = manager + } + + listener?.onProviderReady(forced) + } + } + + // ExtensionsManager.isExtensionAvailable() answers from CameraExtensionCharacteristics' + // static advertisement data. Actually *binding* an advertised extension additionally makes + // CameraX initialize a Camera2ExtensionsVendorExtender, which calls into the vendor's + // advanced extender over binder. Some vendors advertise a mode there and then throw from + // that init - Pixels raise "Framework size list map not supported in pixel path" + // - which used to kill the process from inside bindToLifecycle(). CameraX 1.6 removed the + // legacy OEM extender path, so there is no longer a working fallback on those devices and the + // only safe option is to stop offering the mode. + // + // getCameraInfo() performs exactly the same vendor init that bindToLifecycle() does, so it is + // a faithful probe. Verdicts are cached (ExtensionAvailabilityRepository) because every step of + // the probe -- including the availability query, see probeExtension -- costs a binder round + // trip. A negative verdict is only cached when the failure is known to be persistent: caching + // a transient probe failure would make the mode's tab vanish for the rest of the process + // lifetime over a condition that clears seconds later. + // + // The cache is read and written on the main thread only, which is also what keeps the two + // activities that can share it (a secure session over a running one) from racing each other. + // probeExtension() itself also runs on the probe thread that loadTabs() spawns, but its + // results come back through the main executor. + // + // true/false is a verdict that is safe to cache; null is a failure that may be transient and + // must not be (see ExtensionAvailabilityRepository). The provider and manager are parameters + // rather than the fields because this also runs off the main thread, where the fields could + // be swapped out mid-probe. + + // Refreshing the tabs must not run extension probes on the calling (main) thread: the + // first refresh after process start needs one vendor-extender init round trip over + // binder per advertised mode per lens, which measures at over half a second of blocked + // main thread -- more than a hundred dropped frames -- during startup on a Pixel 7 Pro. + // The probes run on their own short-lived thread instead (not cameraExecutor, which the + // QR analyzer may be draining) and the tabs are built once every verdict is in, so the + // tab bar still appears exactly once, fully formed, at the same time it used to; until + // then swipes and taps resolve to no tab and the app simply stays in the current mode. + // Later refreshes find the cache warm and rebuild synchronously, exactly as before. + override fun probeUnknownExtensions(onRestart: () -> Unit, onSettled: () -> Unit) { + val pending = unprobedExtensions() + if (pending.isEmpty()) { + onSettled() + return + } + + if (extensionProbesInFlight) return + val provider = cameraProvider ?: return + val em = extensionsManager ?: return + extensionProbesInFlight = true + + thread { + val verdicts = HashMap() + for (key in pending) { + verdicts[key] = probeExtension( + provider = provider, + em = em, + selector = selectorFor(key.lensFacing), + extensionMode = key.extensionMode + ) + } + + environment.sessionMainExecutor.execute { + extensionProbesInFlight = false + if (!environment.isSessionActive) return@execute + + if (!snapshotProbeCache.isProbedThrough(provider)) { + // The camera stack was reinitialized while probing: these verdicts describe + // vendor state that no longer exists (the same reasoning as the cache clear + // in initialize). Any refresh that ran for the new provider found this round + // still in flight and skipped scheduling, so start over for it. + onRestart() + return@execute + } + + extensionAvailabilityRepository.recordProbeRound(verdicts) + onSettled() + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun probeExtension( + provider: ProcessCameraProvider, + em: ExtensionsManager, + selector: CameraSelector, + extensionMode: Int, + ): Boolean? { + // What the vendor advertises is as static as the vendor init verdict below, so a + // negative answer is cached the same way -- notably, isExtensionAvailable() is *not* + // the cheap in-process lookup it appears to be: on Android 17 every call costs a round + // trip to the vendor's extensions proxy service, which is exactly the kind of work this + // probe exists to keep off the main thread (see loadTabs). It sits inside the try below so + // that a transient failure of that round trip is treated like any other -- returning null + // to retry later -- rather than propagating out: on the background probe thread an escaping + // exception would strand the round with extensionProbesInFlight still set, blocking every + // later tab refresh. + return try { + when { + !em.isExtensionAvailable(selector, extensionMode) -> false + else -> { + provider.getCameraInfo( + em.getExtensionEnabledCameraSelector(selector, extensionMode) + ) + true + } + } + } catch (exception: UnsupportedOperationException) { + // The signature of a vendor extender that advertises the mode and then throws from + // its own init (Pixels: "Framework size list map not supported in pixel path"). + // Nothing about it changes within a process lifetime, so this verdict is safe to + // remember. + Log.w(TAG, "Extension mode $extensionMode is advertised but unusable here", exception) + false + } catch (exception: Exception) { + // Anything else may be transient — the camera service restarting, the camera briefly + // held by another process. Fail this probe but leave the cache alone so the mode is + // offered again once the underlying condition clears. + Log.w(TAG, "Probing extension mode $extensionMode failed, will retry later", exception) + null + } + } + + private fun isExtensionUsable( + selector: CameraSelector, + lensFacing: Int, + extensionMode: Int, + probeOnMiss: Boolean = true, + ): Boolean { + if (extensionMode == ExtensionMode.NONE) return true + + val em = extensionsManager ?: return false + val provider = cameraProvider ?: return false + + val key = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + extensionAvailabilityRepository.verdict(key)?.let { return it } + + if (!probeOnMiss) return false + + // A background probe round (loadTabs) may already be asking the vendor about this very + // key. Probing inline here would run a second synchronous vendor round trip on the main + // thread and race that round's verdict write. While a round is in flight, treat the miss + // as "unusable for now"; the round fills the cache and the next rebind/tab refresh picks + // up the real verdict. The inline probe stays for the not-in-flight cold case, where it is + // the only path to a verdict. + if (extensionProbesInFlight) return false + + val verdict = probeExtension(provider, em, selector, extensionMode) ?: return false + extensionAvailabilityRepository.record(key, usable = verdict) + return verdict + } + + private fun canVerifyFeatureCombinations(): Boolean { + val cameraInfo = try { + cameraProvider?.getCameraInfo(cameraSelector) + } catch (exception: IllegalArgumentException) { + Log.w(TAG, "Unable to resolve camera info for feature combination gate", exception) + null + } + + return when (cameraInfo) { + null -> false + else -> featureCombinationSupport.canVerify(cameraInfo) + } + } + + // Maps the user-chosen video quality to the equivalent groupable feature, for use when a + // feature group is passed to SessionConfig (see startCamera). Quality.HIGHEST has no + // groupable equivalent and is resolved to the highest quality the current camera supports, + // mirroring what QualitySelector.from(Quality.HIGHEST) would have selected. + private fun videoQualityAsGroupableFeature(videoQuality: Quality): GroupableFeature? { + val quality = when (videoQuality) { + Quality.HIGHEST -> highestSupportedVideoQuality() + else -> videoQuality + } ?: return null + + val feature = videoQualityFeatureMapper.map(quality) + + if (feature == null) { + // Not fatal: startCamera() then requests no quality feature at all and the quality is + // left to Recorder's default selector. Worth a log because it means the user's + // explicit choice is silently not being asked for. + Log.w(TAG, "No groupable feature equivalent for video quality $quality") + } + + return feature + } + + private fun highestSupportedVideoQuality(): Quality? { + val cameraInfo = try { + cameraProvider?.getCameraInfo(cameraSelector) + } catch (exception: IllegalArgumentException) { + Log.w(TAG, "Unable to resolve camera info for quality lookup", exception) + null + } + + return cameraInfo?.let { + Recorder.getVideoCapabilities(it) + .getSupportedQualities(DynamicRange.SDR) + .firstOrNull() + } + } + + // What was asked for -- including which camera it was asked of -- is passed in rather than + // read back from a field: the callback is delivered asynchronously, so a field could already + // describe a later bind by the time this runs, and the message would then name settings (or + // dedup against a camera) that this result never involved. + + override fun isLensFacingSupported(lensFacing: Int, extensionMode: Int): Boolean { + var tCameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + if (extensionMode != ExtensionMode.NONE) { + extensionsManager?.let { em -> + if (!isExtensionUsable(tCameraSelector, lensFacing, extensionMode)) { + return false + } + + try { + tCameraSelector = em.getExtensionEnabledCameraSelector( + tCameraSelector, + extensionMode, + ) + } catch (_: IllegalArgumentException) { + return false + } + } + } + + return cameraProvider?.hasCamera(tCameraSelector) ?: false + } + + @SuppressLint("RestrictedApi") + override fun bind(settings: CameraBindSettings): BindOutcome { + val provider = requireNotNull(cameraProvider) { + "Camera provider is not ready yet" + } + + // Unbind/close all other camera(s) [if any] + provider.unbindAll() + + val extMode = settings.mode.extensionMode + var appliedExtension: ExtensionKey? = null + if (extMode != ExtensionMode.NONE) { + val em = extensionsManager + if (em != null && isExtensionUsable(cameraSelector, lensFacing, extMode)) { + appliedExtension = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extMode, + ) + cameraSelector = em.getExtensionEnabledCameraSelector(cameraSelector, extMode) + } else { + Log.e(TAG, "Mode $settings.mode isn't available for this device") + } + } + + val useCasesList = arrayListOf() + + // CameraX 1.6.0's SessionConfig throws an IllegalArgumentException at construction time + // if any use case configures a groupable feature through a non-groupable API while a + // feature group is in use. Recorder.Builder.setQualitySelector() is such an API since + // 1.6.0 introduced GroupableFeatures.*_RECORDING, so when EIS is requested through + // GroupableFeature.PREVIEW_STABILIZATION (the only case where this app uses a feature + // group), the video quality has to be requested through the feature group as well. + // + // The validation triggers on setQualitySelector() having been called at all, not on the + // quality it was given, so this flag -- not the resolved feature below -- is what decides + // whether that setter may be used. videoQualityAsGroupableFeature() can legitimately fail + // to map the current quality, and falling back to setQualitySelector() in that case would + // reintroduce the very exception this works around. + // + // Both halves of canApplyVideoStabilization() gate the group. The platform has to be able + // to verify feature combinations: where it cannot, the resolver would conflate "could not + // check" with "unsupported", quietly discard the stored video quality and produce + // untruthful notices. Cameras behind that gate use the pre-1.6 non-groupable setters in + // the session plan instead, which are legal exactly because no feature group is in use + // then. And the group exists solely to negotiate stabilization against the quality, so a + // camera that supports no stabilization at all has nothing to negotiate: it takes the + // plain fallback path directly, which produces the identical output without a needless + // feature-group round. + val featureGroup: FeatureGroupRequest = when { + settings.isVideoMode && settings.enableEis && canApplyVideoStabilization() -> { + FeatureGroupRequest.Requested(videoQualityAsGroupableFeature(settings.videoQuality)) + } + + else -> FeatureGroupRequest.Unused + } + + val requiredQualityFeature = when (featureGroup) { + is FeatureGroupRequest.Requested -> featureGroup.videoQualityFeature + FeatureGroupRequest.Unused -> null + } + + val bindRequest = CameraBindRequest( + includesVideoCapture = !settings.isQrMode && settings.isVideoMode, + includesImageCapture = !settings.isQrMode && !settings.requiresVideoModeOnly, + aspectRatio = settings.aspectRatio, + imageCaptureTargetRotation = imageCapture?.targetRotation ?: settings.rotation, + previewTargetRotation = preview?.targetRotation ?: settings.rotation, + flashMode = settings.flashMode, + photoQuality = settings.photoQuality, + waitForFocusLock = settings.waitForFocusLock, + enableZsl = settings.enableZsl, + selectHighestResolution = settings.selectHighestResolution, + videoQuality = settings.videoQuality, + mirrorVideoOnFrontCamera = settings.mirrorVideoOnFrontCamera, + featureGroup = featureGroup, + ) + + val plan = cameraSessionPlanFactory.create(bindRequest) + + if (settings.isQrMode) { + val analyzer = environment.createQrAnalyzer() + val strategy = ResolutionStrategy( + Size(960, 960), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + val mIAnalyzer = ImageAnalysis.Builder() + .setResolutionSelector( + ResolutionSelector.Builder().setResolutionStrategy(strategy).build() + ) + .setOutputImageRotationEnabled(true) + .build() + qrAnalyzer = analyzer + iAnalyzer = mIAnalyzer + mIAnalyzer.setAnalyzer(cameraExecutor, analyzer) + cameraSelector = CameraSelector.Builder() + .requireLensFacing( + requireNotNull(settings.qrLensFacing) { + "QR mode needs a lens facing" + } + ) + .build() + + useCasesList.add(mIAnalyzer) + } else { + plan.videoCapture?.let { + videoCapture = it + useCasesList.add(it) + } + + plan.imageCapture?.let { + imageCapture = it + useCasesList.add(it) + } + } + + preview = plan.preview.also { + useCasesList.add(it) + it.surfaceProvider = environment.previewSurfaceProvider + } + + // Not every camera can run video, photo and preview at once. Ask before binding rather + // than binding and retrying without the photo use case when it throws: an + // IllegalArgumentException from bindToLifecycle() carries no indication of which + // constraint was violated, so the retry could not tell "this camera can't do video plus + // photo" apart from any other misconfiguration, and would answer both by silently + // dropping in-video snapshots. A genuine bug then looked like a missing feature. This + // asks the specific question, and leaves unexpected exceptions to surface as failures. + // + // Asking costs 80-140 ms, because the camera service resolves the whole feature group to + // answer it, so the verdict is cached (SnapshotProbeCache) and every entry into video mode + // after the first is free. + val snapshotUseCase = imageCapture + if (settings.isVideoMode && snapshotUseCase != null) { + val probeKey = SnapshotProbeKey( + lensFacing = lensFacing, + videoQuality = settings.videoQuality, + usesFeatureGroup = featureGroup is FeatureGroupRequest.Requested, + captureMode = plan.captureMode, + selectHighestResolution = settings.selectHighestResolution, + ) + + if (snapshotProbeCache[probeKey] == null) { + snapshotProbeCache.recordProbe() + + val cameraInfo = try { + provider.getCameraInfo(cameraSelector) + } catch (exception: IllegalArgumentException) { + Log.e(TAG, "Failed to query camera info", exception) + return BindOutcome.FAILED + } + + snapshotProbeCache[probeKey] = inVideoSnapshotSupportResolver.resolve( + videoQualityFeature = requiredQualityFeature, + probe = { withSnapshots, features -> + val probedUseCases = when { + withSnapshots -> useCasesList + else -> useCasesList - snapshotUseCase + } + + cameraInfo.isSessionConfigSupported( + SessionConfig( + useCases = probedUseCases, + requiredFeatureGroup = features, + ) + ) + }, + ) + } + + val support = snapshotProbeCache[probeKey] + if (support is InVideoSnapshotSupport.Unsupported) { + Log.i(TAG, "${support.reason}; disabling snapshots while recording") + useCasesList.remove(snapshotUseCase) + imageCapture = null + } + } + + try { + val sessionConfig = SessionConfig( + useCases = useCasesList, + preferredFeatureGroup = plan.preferredFeatures + ) + + if (plan.preferredFeatures.isNotEmpty()) { + val requested = plan.preferredFeatures.toList() + val boundLensFacing = lensFacing + sessionConfig.setFeatureSelectionListener( + environment.sessionMainExecutor + ) { selected -> + listener?.onFeaturesSelected( + boundLensFacing = boundLensFacing, + requested = requested, + qualityFeature = requiredQualityFeature, + selected = selected, + ) + } + } + + camera = provider.bindToLifecycle( + environment.sessionLifecycleOwner, + cameraSelector, + sessionConfig + ) + } catch (exception: RuntimeException) { + // A vendor extension can still fail to initialize at bind time even though the + // pre-flight probe in isExtensionUsable() passed. When one was applied, record the + // failure so the mode stops being offered, rather than letting the exception kill the + // process or -- equally bad -- retrying the same doomed bind on every resume. + val key = appliedExtension + if (key == null) { + // No extension in play: an IllegalArgumentException is a plain unsupported + // configuration (reported and swallowed); anything else is a real bug that must + // stay visible. + if (exception is IllegalArgumentException) { + Log.e(TAG, "Failed to bind use cases", exception) + return BindOutcome.FAILED + } + throw exception + } + + // With an extension applied, only the vendor's known-permanent signatures mean "this + // mode is unusable here": UnsupportedOperationException (a vendor extender that + // advertised the mode and then threw from its own init -- Pixels: "Framework size list + // map not supported in pixel path") and IllegalArgumentException (an extension bind + // always uses the same fixed pair of use cases, so an invalid configuration is as + // permanent as any other vendor failure). Anything else is a real bug and is rethrown + // rather than hidden behind a silent mode switch. + if (exception !is UnsupportedOperationException && + exception !is IllegalArgumentException + ) { + throw exception + } + + Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) + extensionAvailabilityRepository.record(key, usable = false) + + return BindOutcome.EXTENSION_UNUSABLE + } + + return BindOutcome.BOUND + } + + private fun unprobedExtensions(): List { + if (extensionsManager == null || cameraProvider == null) return emptyList() + + return extensionAvailabilityRepository.unprobed() + } + + private fun selectorFor(lensFacing: Int): CameraSelector { + return when (lensFacing) { + CameraSelector.LENS_FACING_FRONT -> FRONT_CAMERA_SELECTOR + else -> REAR_CAMERA_SELECTOR + } + } + + @AssistedFactory + interface Factory { + fun create(environment: CameraSessionEnvironment): CameraSessionImpl + } + + companion object { + private const val TAG = "CameraSession" + + private const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK + + private val FRONT_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .build() + + private val REAR_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt new file mode 100644 index 000000000..7fcde602c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt @@ -0,0 +1,29 @@ +package app.grapheneos.camera.data.camera.session + +import android.content.Context +import androidx.camera.core.Preview +import androidx.lifecycle.LifecycleOwner +import app.grapheneos.camera.TunePlayer +import app.grapheneos.camera.analyzer.QRAnalyzer +import java.util.concurrent.Executor + +interface CameraSessionEnvironment { + + val sessionContext: Context + + val sessionLifecycleOwner: LifecycleOwner + + val sessionMainExecutor: Executor + + val displayRotation: Int + + val isSessionActive: Boolean + + val previewSurfaceProvider: Preview.SurfaceProvider + + fun shouldAskForLocationPermission(): Boolean + + fun createTunePlayer(): TunePlayer + + fun createQrAnalyzer(): QRAnalyzer +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionFactory.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionFactory.kt new file mode 100644 index 000000000..ae4af8fa8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionFactory.kt @@ -0,0 +1,16 @@ +package app.grapheneos.camera.data.camera.session + +import javax.inject.Inject + +interface CameraSessionFactory { + fun create(environment: CameraSessionEnvironment): CameraSession +} + +internal class CameraSessionFactoryImpl @Inject constructor( + private val factory: CameraSessionImpl.Factory, +) : CameraSessionFactory { + + override fun create(environment: CameraSessionEnvironment): CameraSession { + return factory.create(environment) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt new file mode 100644 index 000000000..b3b76e628 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt @@ -0,0 +1,192 @@ +package app.grapheneos.camera.data.camera.session + +import android.annotation.SuppressLint +import androidx.camera.core.ImageCapture +import androidx.camera.core.MirrorMode +import androidx.camera.core.Preview +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.video.GroupableFeatures +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import androidx.camera.video.internal.muxer.MediaMuxerImpl +import app.grapheneos.camera.data.camera.model.CameraBindRequest +import app.grapheneos.camera.data.camera.model.CameraSessionPlan +import app.grapheneos.camera.data.camera.model.FeatureGroupRequest +import app.grapheneos.camera.data.camera.model.ImageCaptureMode +import javax.inject.Inject + +interface CameraSessionPlanFactory { + fun create(request: CameraBindRequest): CameraSessionPlan +} + +@SuppressLint("UnsafeOptInUsageError", "RestrictedApi") +internal class CameraSessionPlanFactoryImpl @Inject constructor() : CameraSessionPlanFactory { + + override fun create(request: CameraBindRequest): CameraSessionPlan { + val aspectRatioStrategy = AspectRatioStrategy( + request.aspectRatio, + AspectRatioStrategy.FALLBACK_RULE_AUTO, + ) + + val captureMode = when { + request.waitForFocusLock -> ImageCaptureMode.MAXIMIZE_QUALITY + request.enableZsl -> ImageCaptureMode.ZERO_SHUTTER_LAG + else -> ImageCaptureMode.MINIMIZE_LATENCY + } + + return CameraSessionPlan( + videoCapture = when { + request.includesVideoCapture -> buildVideoCapture(request) + else -> null + }, + imageCapture = when { + request.includesImageCapture -> buildImageCapture( + request = request, + captureMode = captureMode, + aspectRatioStrategy = aspectRatioStrategy, + ) + + else -> null + }, + preview = buildPreview( + request = request, + aspectRatioStrategy = aspectRatioStrategy, + ), + captureMode = captureMode, + preferredFeatures = preferredFeatures(request), + ) + } + + private fun buildVideoCapture(request: CameraBindRequest): VideoCapture { + val recorderBuilder = Recorder.Builder() + + // camera-video 1.6 writes mp4 through the media3 muxer, which cannot keep up with + // 2160p on a Tensor device: the audio queue overflows a few seconds in and stop + // then has to drain everything the muxer is behind by. The platform muxer, which + // is what every release up to 1.5 used, keeps up. Both live in an internal + // package, so this has to be re-checked on every camera-video upgrade. + recorderBuilder.setMuxerFactory { MediaMuxerImpl() } + + when (request.featureGroup) { + is FeatureGroupRequest.Requested -> {} + FeatureGroupRequest.Unused -> { + recorderBuilder.setQualitySelector(QualitySelector.from(request.videoQuality)) + } + } + + val videoCaptureBuilder = VideoCapture.Builder(recorderBuilder.build()) + + // On cameras where the feature group is not used (see canApplyVideoStabilization) + // EIS is deliberately left off. The pre-1.6 stabilization setters cannot be applied + // on top of the recorder's higher qualities -- UHD in particular is in none of the + // stabilization-guaranteed configurations -- so requesting them would either kill + // the preview or force the quality down. We keep the selected quality (4K by + // default) and simply do not stabilize. This is the app's long-standing behavior + // (EIS regressed here when it moved off the Camera2 API); the toggle is hidden + // rather than left inert on these cameras, and implementing EIS for them -- the + // pre-1.6 setters, restricted to the qualities that permit them -- is a separate + // change that needs a device the feature group cannot serve to test on. + + if (request.mirrorVideoOnFrontCamera) { + videoCaptureBuilder.setMirrorMode(MirrorMode.MIRROR_MODE_ON_FRONT_ONLY) + } + + return videoCaptureBuilder.build() + } + + private fun buildImageCapture( + request: CameraBindRequest, + captureMode: ImageCaptureMode, + aspectRatioStrategy: AspectRatioStrategy, + ): ImageCapture { + val resolutionSelectorBuilder = ResolutionSelector.Builder() + .setAspectRatioStrategy(aspectRatioStrategy) + + if (request.selectHighestResolution) { + resolutionSelectorBuilder.setAllowedResolutionMode( + ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE + ) + } + + return ImageCapture.Builder() + .setCaptureMode(cameraXCaptureMode(captureMode)) + .setTargetRotation(request.imageCaptureTargetRotation) + .setResolutionSelector(resolutionSelectorBuilder.build()) + .setFlashMode(request.flashMode) + .setJpegQuality(request.photoQuality) + .build() + } + + private fun cameraXCaptureMode(captureMode: ImageCaptureMode): Int { + return when (captureMode) { + ImageCaptureMode.MAXIMIZE_QUALITY -> ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY + ImageCaptureMode.ZERO_SHUTTER_LAG -> ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG + ImageCaptureMode.MINIMIZE_LATENCY -> ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY + } + } + + private fun buildPreview( + request: CameraBindRequest, + aspectRatioStrategy: AspectRatioStrategy, + ): Preview { + val previewBuilder = Preview.Builder() + .setTargetRotation(request.previewTargetRotation) + .setResolutionSelector( + ResolutionSelector.Builder() + .setAspectRatioStrategy(aspectRatioStrategy) + .build() + ) + + // Pixels and potentially other devices enable EIS by default, which reduces the field of + // view and image quality for image capture if it's not explicitly disabled. + // + // setPreviewStabilizationEnabled() is one of the non-groupable setters that SessionConfig + // rejects outright once a feature group is in use, so it must not be called at all -- + // with either value -- when stabilization is being requested through the feature group + // below. On every other path it is explicitly disabled: EIS is not applied off the feature + // group (see the VideoCapture builder above), and leaving it unset would let a device + // default preview stabilization cost photo capture its field of view. + when (request.featureGroup) { + is FeatureGroupRequest.Requested -> {} + FeatureGroupRequest.Unused -> previewBuilder.setPreviewStabilizationEnabled(false) + } + + return previewBuilder.build() + } + + // The list ordering encodes priority (highest priority first): CameraX walks the subsets + // of this list in order and binds the first one the camera supports, so trailing features + // are the ones given up first. + // + // The video quality leads because it is an explicit, deliberate choice from a spinner, + // whereas stabilization is a toggle that defaults to on and that most users never touch; + // an explicit choice should not lose to a default. This inverts the CameraX 1.5.x + // behaviour, where asking for 2160p on a Pixel silently recorded at 1080p because + // stabilization won. Whatever is given up is now reported by onFeaturesSelected(). + // + // Both stabilization features are listed because the EIS toggle is offered whenever + // either kind is supported (see canApplyVideoStabilization). They share one feature + // type, so CameraX never selects both and skips the subsets containing the pair; the + // effective order is quality+preview-stabilization, quality+video-stabilization, quality + // alone, then the same three without the quality. Preview stabilization is preferred + // because it stabilizes the preview and the recording alike, making the framing that is + // shown the framing that is recorded, while video stabilization only stabilizes the file. + // + // If the quality feature is dropped the quality follows Recorder's default quality + // selector (FHD, HD, SD in that order). + private fun preferredFeatures(request: CameraBindRequest): List { + val featureGroup = request.featureGroup + if (featureGroup !is FeatureGroupRequest.Requested) return emptyList() + + val features = arrayListOf() + + featureGroup.videoQualityFeature?.let { features.add(it) } + features.add(GroupableFeature.PREVIEW_STABILIZATION) + features.add(GroupableFeatures.VIDEO_STABILIZATION) + + return features + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt new file mode 100644 index 000000000..4ef0d9dca --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt @@ -0,0 +1,54 @@ +package app.grapheneos.camera.data.camera.session + +import android.hardware.camera2.CameraCharacteristics +import android.os.Build +import android.util.Log +import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import androidx.camera.core.CameraInfo +import javax.inject.Inject + +// Whether CameraX's feature-group resolution can actually *verify* feature combinations on +// the current camera. The resolver (DefaultFeatureGroupResolver) keeps a candidate +// combination only when the platform confirms it, and it treats "could not check" exactly +// like "verified unsupported": below API 35 CameraX substitutes a no-op feature-combination +// query whose isSupported() is unconditionally false (CameraSurfaceAdapter), and on API 35+ +// a HAL that does not implement the session-configuration query answers UNKNOWN, which is +// folded into false as well (camera-pipe ConfigQueryResult). On such a camera, combinations +// the device records fine every day resolve down to little or nothing: the stored video +// quality is silently ignored (setQualitySelector must not be called while a feature group +// is in use, see startCamera) and the divergence notice asserts unsupportedness that was +// never actually verified. So the feature-group path is only taken when the platform can +// genuinely answer, and startCamera otherwise falls back to the pre-1.6 configuration APIs, +// which never drop the chosen quality. +interface FeatureCombinationSupport { + fun canVerify(cameraInfo: CameraInfo): Boolean +} + +internal class FeatureCombinationSupportImpl @Inject constructor() : FeatureCombinationSupport { + + // The per-camera INFO_SESSION_CONFIGURATION_QUERY_VERSION characteristic is what the + // platform's answers ultimately hinge on (CameraDeviceSetup exists only for cameras + // reporting >= 35 there), so it is checked in addition to the API level. This deliberately + // mirrors CameraX's own SDK_INT >= 35 gate — which upstream may still move, see b/417839748 + // — plus the HAL capability that gate cannot see; re-check both on CameraX updates. + @androidx.annotation.OptIn(ExperimentalCamera2Interop::class) + override fun canVerify(cameraInfo: CameraInfo): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return false + + val queryVersion = try { + Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic( + CameraCharacteristics.INFO_SESSION_CONFIGURATION_QUERY_VERSION + ) + } catch (exception: IllegalArgumentException) { + Log.w(TAG, "Camera info carries no camera2 characteristics", exception) + null + } + + return (queryVersion ?: 0) >= Build.VERSION_CODES.VANILLA_ICE_CREAM + } + + private companion object { + const val TAG = "FeatureCombination" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt new file mode 100644 index 000000000..0d04fb57c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt @@ -0,0 +1,64 @@ +package app.grapheneos.camera.data.camera.session + +import androidx.camera.core.featuregroup.GroupableFeature +import app.grapheneos.camera.data.camera.model.InVideoSnapshotSupport +import javax.inject.Inject + +fun interface SessionProbe { + fun isSupported(withSnapshots: Boolean, features: Set): Boolean +} + +interface InVideoSnapshotSupportResolver { + + fun resolve( + videoQualityFeature: GroupableFeature?, + probe: SessionProbe, + ): InVideoSnapshotSupport +} + +internal class InVideoSnapshotSupportResolverImpl @Inject constructor() : + InVideoSnapshotSupportResolver { + + override fun resolve( + videoQualityFeature: GroupableFeature?, + probe: SessionProbe, + ): InVideoSnapshotSupport { + // When a quality feature is about to be requested, the probe has to require that + // quality too: a camera can be able to run the three plain streams yet not at the + // chosen quality, and a probe without it would keep the snapshot use case and leave + // the conflict to the feature-group resolver -- which resolves it by dropping the + // *quality*, with a notice blaming the camera for a quality it does support. The + // quality wins the conflict because it is an explicit choice from the settings while + // in-video snapshots are an implicit capability (the same reasoning as the preferred + // feature ordering in startCamera), and giving up the snapshots is already the + // established answer when they can't be bound at all. The plain-stream fallback keeps + // the snapshots when the quality is unreachable even without them: dropping them would + // buy nothing, and the resolver's "unsupported quality" notice is genuinely true then. + return when { + videoQualityFeature == null -> plainStreamSupport(probe) + + probe.isSupported( + withSnapshots = true, + features = setOf(videoQualityFeature), + ) -> InVideoSnapshotSupport.Supported + + probe.isSupported( + withSnapshots = false, + features = setOf(videoQualityFeature), + ) -> InVideoSnapshotSupport.Unsupported.SELECTED_VIDEO_QUALITY + + else -> plainStreamSupport(probe) + } + } + + private fun plainStreamSupport(probe: SessionProbe): InVideoSnapshotSupport { + return when { + probe.isSupported( + withSnapshots = true, + features = emptySet(), + ) -> InVideoSnapshotSupport.Supported + + else -> InVideoSnapshotSupport.Unsupported.STREAM_COMBINATION + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/session/SnapshotProbeCache.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/SnapshotProbeCache.kt new file mode 100644 index 000000000..16bf76cf2 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/SnapshotProbeCache.kt @@ -0,0 +1,65 @@ +package app.grapheneos.camera.data.camera.session + +import androidx.annotation.VisibleForTesting +import androidx.camera.lifecycle.ProcessCameraProvider +import app.grapheneos.camera.data.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.data.camera.model.SnapshotProbeKey +import javax.inject.Inject + +interface SnapshotProbeCache { + + val probeCount: Int + + fun isProbedThrough(provider: ProcessCameraProvider): Boolean + + fun adopt(provider: ProcessCameraProvider) + + operator fun get(key: SnapshotProbeKey): InVideoSnapshotSupport? + + operator fun set(key: SnapshotProbeKey, support: InVideoSnapshotSupport) + + fun recordProbe() + + @VisibleForTesting + fun clear() +} + +internal class SnapshotProbeCacheImpl @Inject constructor() : SnapshotProbeCache { + + // A cache hit and a repeated probe reach the same verdict, so this is the only thing that + // tells them apart from the outside. + override var probeCount: Int = 0 + private set + + private val verdicts = HashMap() + + // The provider the verdicts above were probed through. A different instance means the + // camera stack was reinitialized and none of them describe it any more. + private var probedProvider: ProcessCameraProvider? = null + + override fun isProbedThrough(provider: ProcessCameraProvider): Boolean { + return probedProvider === provider + } + + override fun adopt(provider: ProcessCameraProvider) { + verdicts.clear() + probedProvider = provider + } + + override fun get(key: SnapshotProbeKey): InVideoSnapshotSupport? { + return verdicts[key] + } + + override fun set(key: SnapshotProbeKey, support: InVideoSnapshotSupport) { + verdicts[key] = support + } + + override fun recordProbe() { + probeCount++ + } + + override fun clear() { + verdicts.clear() + probeCount = 0 + } +} diff --git a/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt b/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt index 395103260..37efbf81b 100644 --- a/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt +++ b/app/src/main/java/app/grapheneos/camera/data/core/model/CameraMode.kt @@ -13,4 +13,15 @@ enum class CameraMode( HDR(ExtensionMode.HDR), CAMERA(ExtensionMode.NONE), VIDEO(ExtensionMode.NONE), + ; + + val isQr: Boolean + get() { + return this == QR_SCAN + } + + val isVideo: Boolean + get() { + return this == VIDEO + } } diff --git a/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt index 5f1030eb9..fc47d8fce 100644 --- a/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt +++ b/app/src/main/java/app/grapheneos/camera/data/media/repository/CapturedItemRepository.kt @@ -16,15 +16,18 @@ import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.data.media.store.MediaPrefs import app.grapheneos.camera.data.media.store.StoragePrefs import app.grapheneos.camera.data.media.store.StoredCapturedItem +import app.grapheneos.camera.di.core.IoDispatcher import dagger.hilt.android.qualifiers.ActivityContext import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext interface CapturedItemRepository { - suspend fun lastCapturedItem(): CapturedItem? + val lastCapturedItem: Flow suspend fun saveLastCapturedItem(item: CapturedItem) @@ -34,7 +37,7 @@ interface CapturedItemRepository { suspend fun releaseUntrackedSafTrees() - suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) + suspend fun migrateStoredCaptures(): CapturedItem? suspend fun capturedItems(): List @@ -43,21 +46,21 @@ interface CapturedItemRepository { } } -@Suppress("TooManyFunctions") internal class CapturedItemRepositoryImpl @Inject constructor( private val storagePrefs: DataStore, private val mediaPrefs: DataStore, @ActivityContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : CapturedItemRepository { - override suspend fun lastCapturedItem(): CapturedItem? { - val stored = mediaPrefs.data.first().lastCapturedItem ?: return null - - return CapturedItem( - type = stored.type, - dateString = stored.dateString, - uri = stored.uri.toUri(), - ) + override val lastCapturedItem: Flow = mediaPrefs.data.map { prefs -> + prefs.lastCapturedItem?.let { stored -> + CapturedItem( + type = stored.type, + dateString = stored.dateString, + uri = stored.uri.toUri(), + ) + } } override suspend fun saveLastCapturedItem(item: CapturedItem) { @@ -105,49 +108,56 @@ internal class CapturedItemRepositoryImpl @Inject constructor( @Suppress("TooGenericExceptionCaught") override suspend fun releaseUntrackedSafTrees() { - val tracked = trackedSafTrees() - val resolver = context.contentResolver - - resolver.persistedUriPermissions.forEach { permission -> - val uri = permission.uri - val flags = CapturedItems.safTreeFlagsToRelease( - uri, - permission.isReadPermission, - permission.isWritePermission, - tracked, - ) - if (flags == 0) { - return@forEach - } + withContext(ioDispatcher) { + val tracked = trackedSafTrees() + val resolver = context.contentResolver + + resolver.persistedUriPermissions.forEach { permission -> + val uri = permission.uri + val flags = CapturedItems.safTreeFlagsToRelease( + uri, + permission.isReadPermission, + permission.isWritePermission, + tracked, + ) + if (flags == 0) { + return@forEach + } - try { - resolver.releasePersistableUriPermission(uri, flags) - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - Log.d(CapturedItems.TAG, "unable to release the grant for $uri", e) + try { + resolver.releasePersistableUriPermission(uri, flags) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.d(CapturedItems.TAG, "unable to release the grant for $uri", e) + } } } } } - override suspend fun migrateStoredCaptures(onLastCapturedItem: (CapturedItem) -> Unit) { - val joinedUris = storagePrefs.data.first().legacyMediaUris ?: return - val uris = joinedUris.split(LEGACY_MEDIA_URI_SEPARATOR).map { it.toUri() } + override suspend fun migrateStoredCaptures(): CapturedItem? { + return withContext(ioDispatcher) { + val joinedUris = storagePrefs.data.first().legacyMediaUris + ?: return@withContext null + val uris = joinedUris.split(LEGACY_MEDIA_URI_SEPARATOR).map { it.toUri() } - uris.firstOrNull { it.authority != null }?.let { - reportLastCapturedItem(it, onLastCapturedItem) - } + val migrated = uris.firstOrNull { it.authority != null }?.let { + legacyCapturedItem(it) + } - val trees = legacyTrees(uris) + val trees = legacyTrees(uris) - storagePrefs.updateData { prefs -> - when { - trees.isEmpty() -> prefs.copy(legacyMediaUris = null) - else -> prefs.copy( - previousSafTrees = trees.map { it.toString() }, - legacyMediaUris = null, - ) + storagePrefs.updateData { prefs -> + when { + trees.isEmpty() -> prefs.copy(legacyMediaUris = null) + else -> prefs.copy( + previousSafTrees = trees.map { it.toString() }, + legacyMediaUris = null, + ) + } } + + migrated } } @@ -281,7 +291,7 @@ internal class CapturedItemRepositoryImpl @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private fun reportLastCapturedItem(uri: Uri, onLastCapturedItem: (CapturedItem) -> Unit) { + private fun legacyCapturedItem(uri: Uri): CapturedItem? { val columnName = when (uri.authority) { MediaStore.AUTHORITY -> MediaStore.MediaColumns.DISPLAY_NAME else -> DocumentsContract.Document.COLUMN_DISPLAY_NAME @@ -301,8 +311,8 @@ internal class CapturedItemRepositoryImpl @Inject constructor( } } - fileName?.let { name -> - CapturedItems.parseCapturedItem(name, uri)?.let(onLastCapturedItem) + return fileName?.let { name -> + CapturedItems.parseCapturedItem(name, uri) } } diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt index 5b0cae222..85cada369 100644 --- a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaPrefsMigration.kt @@ -16,20 +16,17 @@ internal class MediaPrefsMigration( ) : DataMigration { override suspend fun shouldMigrate(currentData: MediaPrefs): Boolean { - return legacyMediaPreferences().all.keys.any(::owns) || - legacyCommonPreferences(context).all.keys.any(::owns) + return legacyCommonPreferences(context).all.keys.any(::owns) } override suspend fun migrate(currentData: MediaPrefs): MediaPrefs { val stored = currentData.lastCapturedItem - ?: readLastCapturedItem(legacyMediaPreferences()) ?: readLastCapturedItem(legacyCommonPreferences(context)) return currentData.copy(lastCapturedItem = stored) } override suspend fun cleanUp() { - context.deleteSharedPreferences(LEGACY_MEDIA_PREFS_NAME) removeLegacyCommonKeys(context, ::owns) } @@ -52,12 +49,4 @@ internal class MediaPrefsMigration( } } } - - private fun legacyMediaPreferences(): SharedPreferences { - return context.getSharedPreferences(LEGACY_MEDIA_PREFS_NAME, Context.MODE_PRIVATE) - } - - private companion object { - const val LEGACY_MEDIA_PREFS_NAME = "media" - } } diff --git a/app/src/main/java/app/grapheneos/camera/data/media/store/MediaStoreCollections.kt b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaStoreCollections.kt new file mode 100644 index 000000000..9dc55392f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/media/store/MediaStoreCollections.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.data.media.store + +import android.net.Uri +import android.provider.MediaStore + +val imageCollectionUri: Uri = requireNotNull( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) +) { "the primary external volume has no image collection" } + +val videoCollectionUri: Uri = requireNotNull( + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) +) { "the primary external volume has no video collection" } diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSlot.kt b/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSlot.kt new file mode 100644 index 000000000..b51a112e3 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSlot.kt @@ -0,0 +1,8 @@ +package app.grapheneos.camera.data.settings.model + +import app.grapheneos.camera.data.core.model.CameraMode + +data class ModeSlot( + val mode: CameraMode, + val isFrontFacing: Boolean, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt index 6e7535da8..591894ec2 100644 --- a/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt +++ b/app/src/main/java/app/grapheneos/camera/data/settings/repository/SettingsRepository.kt @@ -2,92 +2,104 @@ package app.grapheneos.camera.data.settings.repository import androidx.camera.video.Quality import androidx.datastore.core.DataStore -import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper import app.grapheneos.camera.data.settings.model.CameraSettings import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.ModeSlot import app.grapheneos.camera.data.settings.store.SettingsPrefs import app.grapheneos.camera.data.settings.store.StoredModeSettings -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +/** + * One instance per storage, shared by every screen reading it: [settings] is settled before + * [update] returns, so a write made on one screen is readable on another without waiting for a + * dispatch. A screen holding its own copy would only see the write a main-thread turn later. + */ interface SettingsRepository { - val settings: Flow + val settings: StateFlow suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings - suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings + suspend fun modeSettings(slot: ModeSlot): ModeSettings - suspend fun setFlashMode(value: Int): ModeSettings? + suspend fun setFlashMode(slot: ModeSlot, value: Int): ModeSettings - suspend fun setGeoTagging(value: Boolean): ModeSettings? + suspend fun setGeoTagging(slot: ModeSlot, value: Boolean): ModeSettings - suspend fun setSelfIllumination(value: Boolean): ModeSettings? + suspend fun setSelfIllumination(slot: ModeSlot, value: Boolean): ModeSettings - suspend fun setVideoQuality(value: Quality): ModeSettings? + suspend fun setVideoQuality(slot: ModeSlot, value: Quality): ModeSettings } -internal class SettingsRepositoryImpl @Inject constructor( +internal class SettingsRepositoryImpl( private val dataStore: DataStore, private val cameraSettingsMapper: CameraSettingsMapper, private val modeSettingsMapper: ModeSettingsMapper, private val storedVideoQualityMapper: StoredVideoQualityMapper, ) : SettingsRepository { - override val settings: Flow = dataStore.data.map { - cameraSettingsMapper.map(it.common) - } + private val storedSettings = MutableStateFlow( + runBlocking { cameraSettingsMapper.map(dataStore.data.first().common) }, + ) - private var slotted: SlottedMode? = null + override val settings: StateFlow = storedSettings.asStateFlow() override suspend fun update(transform: (CameraSettings) -> CameraSettings): CameraSettings { - return dataStore + val updated = dataStore .updateData { prefs -> val settings = transform(cameraSettingsMapper.map(prefs.common)) prefs.copy(common = cameraSettingsMapper.map(settings)) } .let { cameraSettingsMapper.map(it.common) } - } - override suspend fun selectMode(mode: CameraMode, isFrontFacing: Boolean): ModeSettings { - slotted = SlottedMode(mode = mode, isFrontFacing = isFrontFacing) + storedSettings.value = updated + + return updated + } + override suspend fun modeSettings(slot: ModeSlot): ModeSettings { return modeSettingsMapper.map( - stored = dataStore.data.first().mode(mode), - isFrontFacing = isFrontFacing, + stored = dataStore.data.first().mode(slot.mode), + isFrontFacing = slot.isFrontFacing, ) } - override suspend fun setFlashMode(value: Int): ModeSettings? { + override suspend fun setFlashMode(slot: ModeSlot, value: Int): ModeSettings { return writeMode( - store = { stored, _ -> stored.copy(flashMode = value) }, + slot = slot, + store = { stored -> stored.copy(flashMode = value) }, asRequested = { it.copy(flashMode = value) }, ) } - override suspend fun setGeoTagging(value: Boolean): ModeSettings? { + override suspend fun setGeoTagging(slot: ModeSlot, value: Boolean): ModeSettings { return writeMode( - store = { stored, _ -> stored.copy(geoTagging = value) }, + slot = slot, + store = { stored -> stored.copy(geoTagging = value) }, asRequested = { it.copy(geoTagging = value) }, ) } - override suspend fun setSelfIllumination(value: Boolean): ModeSettings? { + override suspend fun setSelfIllumination(slot: ModeSlot, value: Boolean): ModeSettings { return writeMode( - store = { stored, _ -> stored.copy(selfIllumination = value) }, + slot = slot, + store = { stored -> stored.copy(selfIllumination = value) }, asRequested = { it.copy(selfIllumination = value) }, ) } - override suspend fun setVideoQuality(value: Quality): ModeSettings? { + override suspend fun setVideoQuality(slot: ModeSlot, value: Quality): ModeSettings { return writeMode( - store = { stored, slot -> + slot = slot, + store = { stored -> val quality = storedVideoQualityMapper.map(value) when { @@ -100,13 +112,12 @@ internal class SettingsRepositoryImpl @Inject constructor( } private suspend fun writeMode( - store: (StoredModeSettings, SlottedMode) -> StoredModeSettings, + slot: ModeSlot, + store: (StoredModeSettings) -> StoredModeSettings, asRequested: (ModeSettings) -> ModeSettings, - ): ModeSettings? { - val slot = slotted ?: return null - + ): ModeSettings { val prefs = dataStore.updateData { prefs -> - val stored = store(prefs.mode(slot.mode), slot) + val stored = store(prefs.mode(slot.mode)) prefs.withMode(mode = slot.mode, settings = stored) } @@ -118,9 +129,4 @@ internal class SettingsRepositoryImpl @Inject constructor( ), ) } - - private data class SlottedMode( - val mode: CameraMode, - val isFrontFacing: Boolean, - ) } diff --git a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt new file mode 100644 index 000000000..8c8a9a381 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -0,0 +1,85 @@ +package app.grapheneos.camera.di.camera + +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapperImpl +import app.grapheneos.camera.data.camera.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.CameraProviderSourceImpl +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepositoryImpl +import app.grapheneos.camera.data.camera.session.CameraSessionFactory +import app.grapheneos.camera.data.camera.session.CameraSessionFactoryImpl +import app.grapheneos.camera.data.camera.session.CameraSessionPlanFactory +import app.grapheneos.camera.data.camera.session.CameraSessionPlanFactoryImpl +import app.grapheneos.camera.data.camera.session.FeatureCombinationSupport +import app.grapheneos.camera.data.camera.session.FeatureCombinationSupportImpl +import app.grapheneos.camera.data.camera.session.InVideoSnapshotSupportResolver +import app.grapheneos.camera.data.camera.session.InVideoSnapshotSupportResolverImpl +import app.grapheneos.camera.data.camera.session.SnapshotProbeCache +import app.grapheneos.camera.data.camera.session.SnapshotProbeCacheImpl +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModesImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class CameraBindsModule { + + @Binds + @Singleton + abstract fun bindExtensionAvailabilityRepository( + impl: ExtensionAvailabilityRepositoryImpl, + ): ExtensionAvailabilityRepository + + @Binds + @Reusable + abstract fun bindCameraProviderSource( + impl: CameraProviderSourceImpl, + ): CameraProviderSource + + @Binds + @Reusable + abstract fun bindFeatureCombinationSupport( + impl: FeatureCombinationSupportImpl, + ): FeatureCombinationSupport + + @Binds + @Reusable + abstract fun bindVideoQualityFeatureMapper( + impl: VideoQualityFeatureMapperImpl, + ): VideoQualityFeatureMapper + + @Binds + @Reusable + abstract fun bindCameraSessionPlanFactory( + impl: CameraSessionPlanFactoryImpl, + ): CameraSessionPlanFactory + + @Binds + @Reusable + abstract fun bindResolveAvailableModes( + impl: ResolveAvailableModesImpl, + ): ResolveAvailableModes + + @Binds + @Reusable + abstract fun bindInVideoSnapshotSupportResolver( + impl: InVideoSnapshotSupportResolverImpl, + ): InVideoSnapshotSupportResolver + + @Binds + @Singleton + abstract fun bindSnapshotProbeCache( + impl: SnapshotProbeCacheImpl, + ): SnapshotProbeCache + + @Binds + @Reusable + abstract fun bindCameraSessionFactory( + impl: CameraSessionFactoryImpl, + ): CameraSessionFactory +} diff --git a/app/src/main/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModule.kt new file mode 100644 index 000000000..9adbf6a72 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModule.kt @@ -0,0 +1,37 @@ +package app.grapheneos.camera.di.camera + +import android.content.Context +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.ui.activities.CaptureActivity +import app.grapheneos.camera.ui.activities.QrTile +import app.grapheneos.camera.ui.activities.SecureActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import app.grapheneos.camera.ui.activities.VideoCaptureActivity +import app.grapheneos.camera.ui.activities.VideoOnlyActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal class CameraEntryPointProvidesModule { + + @Provides + @ActivityScoped + fun provideCameraEntryPoint(@ActivityContext context: Context): CameraEntryPoint { + return CameraEntryPoint( + isSecureSession = context is SecureActivity, + isCaptureSession = context is CaptureActivity, + isVideoOnlySession = context is VideoOnlyActivity, + requiresVideoModeOnly = context is VideoOnlyActivity || + context is VideoCaptureActivity, + allowsQrScanning = context !is SecureMainActivity, + showsCameraModeTabs = context !is VideoOnlyActivity && + context !is CaptureActivity && + context !is QrTile, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/camera/CameraSessionBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraSessionBindsModule.kt new file mode 100644 index 000000000..b8e278a6b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraSessionBindsModule.kt @@ -0,0 +1,20 @@ +package app.grapheneos.camera.di.camera + +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQualityImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class CameraSessionBindsModule { + + @Binds + @ActivityScoped + abstract fun bindResolveDroppedVideoQuality( + impl: ResolveDroppedVideoQualityImpl, + ): ResolveDroppedVideoQuality +} diff --git a/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt index 34765dcfd..43c2ca3fb 100644 --- a/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/core/CoroutinesProvidesModule.kt @@ -20,6 +20,12 @@ internal class CoroutinesProvidesModule { return Dispatchers.IO } + @Provides + @MainImmediateDispatcher + fun provideMainImmediateDispatcher(): CoroutineDispatcher { + return Dispatchers.Main.immediate + } + @Provides @Singleton @ApplicationScope diff --git a/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt index fa53fc46f..ea40156b3 100644 --- a/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt +++ b/app/src/main/java/app/grapheneos/camera/di/core/Qualifiers.kt @@ -6,6 +6,10 @@ import javax.inject.Qualifier @Qualifier annotation class IoDispatcher +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class MainImmediateDispatcher + @Retention(AnnotationRetention.BINARY) @Qualifier annotation class ApplicationScope diff --git a/app/src/main/java/app/grapheneos/camera/di/gallery/GalleryBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/gallery/GalleryBindsModule.kt new file mode 100644 index 000000000..ac1e61064 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/gallery/GalleryBindsModule.kt @@ -0,0 +1,37 @@ +package app.grapheneos.camera.di.gallery + +import app.grapheneos.camera.domain.gallery.CapturedItemSession +import app.grapheneos.camera.domain.gallery.CapturedItemSessionImpl +import app.grapheneos.camera.domain.gallery.mapper.VisibleCaptureMapper +import app.grapheneos.camera.domain.gallery.mapper.VisibleCaptureMapperImpl +import app.grapheneos.camera.domain.gallery.usecase.RevertToMediaStoreLocation +import app.grapheneos.camera.domain.gallery.usecase.RevertToMediaStoreLocationImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class GalleryBindsModule { + + @Binds + @ActivityScoped + abstract fun bindCapturedItemSession( + impl: CapturedItemSessionImpl, + ): CapturedItemSession + + @Binds + @Reusable + abstract fun bindVisibleCaptureMapper( + impl: VisibleCaptureMapperImpl, + ): VisibleCaptureMapper + + @Binds + @Reusable + abstract fun bindRevertToMediaStoreLocation( + impl: RevertToMediaStoreLocationImpl, + ): RevertToMediaStoreLocation +} diff --git a/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt index 49cbdfadb..8f3bbf99a 100644 --- a/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt @@ -2,10 +2,11 @@ package app.grapheneos.camera.di.preferences import android.content.Context import androidx.datastore.core.DataStore -import app.grapheneos.camera.data.core.store.InMemoryDataStore import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.data.settings.store.SettingsPrefs import app.grapheneos.camera.di.core.DurablePreferences +import app.grapheneos.camera.di.settings.SettingsRepositoryFactory import app.grapheneos.camera.ui.activities.SecureActivity import dagger.Module import dagger.Provides @@ -13,8 +14,6 @@ import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking @Module @InstallIn(ActivityComponent::class) @@ -25,10 +24,30 @@ internal class PreferencesProvidesModule { fun provideSettingsPrefs( @ActivityContext context: Context, @DurablePreferences durable: DataStore, + secureSession: SecureSessionPreferences, ): DataStore { return when (context) { // Secure sessions get a snapshot so later owner changes cannot leak through the lockscreen. - is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + is SecureActivity -> secureSession.settingsSnapshotOf(durable) + else -> durable + } + } + + @Provides + @ActivityScoped + fun provideSettingsRepository( + @ActivityContext context: Context, + @DurablePreferences durable: SettingsRepository, + @DurablePreferences durablePrefs: DataStore, + secureSession: SecureSessionPreferences, + factory: SettingsRepositoryFactory, + ): SettingsRepository { + return when (context) { + is SecureActivity -> secureSession.settingsRepository( + durable = durablePrefs, + create = factory::create, + ) + else -> durable } } @@ -38,9 +57,10 @@ internal class PreferencesProvidesModule { fun provideStoragePrefs( @ActivityContext context: Context, @DurablePreferences durable: DataStore, + secureSession: SecureSessionPreferences, ): DataStore { return when (context) { - is SecureActivity -> InMemoryDataStore(runBlocking { durable.data.first() }) + is SecureActivity -> secureSession.storageSnapshotOf(durable) else -> durable } } diff --git a/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt new file mode 100644 index 000000000..3984ceea0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt @@ -0,0 +1,55 @@ +package app.grapheneos.camera.di.preferences + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.media.store.StoragePrefs +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@Singleton +internal class SecureSessionPreferences @Inject constructor() { + + private var settings: DataStore? = null + private var storage: DataStore? = null + private var sessionSettingsRepository: SettingsRepository? = null + private var openActivities = 0 + + fun settingsSnapshotOf(durable: DataStore): DataStore { + return settings ?: snapshotOf(durable).also { settings = it } + } + + fun settingsRepository( + durable: DataStore, + create: (DataStore) -> SettingsRepository, + ): SettingsRepository { + return sessionSettingsRepository + ?: create(settingsSnapshotOf(durable)).also { sessionSettingsRepository = it } + } + + fun storageSnapshotOf(durable: DataStore): DataStore { + return storage ?: snapshotOf(durable).also { storage = it } + } + + fun onSecureActivityCreated() { + openActivities++ + } + + fun onSecureActivityDestroyed() { + openActivities-- + + if (openActivities <= 0) { + openActivities = 0 + settings = null + storage = null + sessionSettingsRepository = null + } + } + + private fun snapshotOf(durable: DataStore): DataStore { + return InMemoryDataStore(runBlocking { durable.data.first() }) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt deleted file mode 100644 index 2c3db413f..000000000 --- a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.grapheneos.camera.di.settings - -import app.grapheneos.camera.data.settings.repository.SettingsRepository -import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped - -@Module -@InstallIn(ActivityComponent::class) -internal abstract class SettingsBindsModule { - - @Binds - @ActivityScoped - abstract fun bindSettingsRepository( - impl: SettingsRepositoryImpl, - ): SettingsRepository -} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsProvidesModule.kt new file mode 100644 index 000000000..21795bd55 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsProvidesModule.kt @@ -0,0 +1,26 @@ +package app.grapheneos.camera.di.settings + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.di.core.DurablePreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal class SettingsProvidesModule { + + @Provides + @Singleton + @DurablePreferences + fun provideDurableSettingsRepository( + @DurablePreferences dataStore: DataStore, + factory: SettingsRepositoryFactory, + ): SettingsRepository { + return factory.create(dataStore) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/di/settings/SettingsRepositoryFactory.kt b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsRepositoryFactory.kt new file mode 100644 index 000000000..4f4a1d101 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/settings/SettingsRepositoryFactory.kt @@ -0,0 +1,28 @@ +package app.grapheneos.camera.di.settings + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapper +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapper +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapper +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class SettingsRepositoryFactory @Inject constructor( + private val cameraSettingsMapper: CameraSettingsMapper, + private val modeSettingsMapper: ModeSettingsMapper, + private val storedVideoQualityMapper: StoredVideoQualityMapper, +) { + + fun create(dataStore: DataStore): SettingsRepository { + return SettingsRepositoryImpl( + dataStore = dataStore, + cameraSettingsMapper = cameraSettingsMapper, + modeSettingsMapper = modeSettingsMapper, + storedVideoQualityMapper = storedVideoQualityMapper, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraEntryPoint.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraEntryPoint.kt new file mode 100644 index 000000000..296513fbc --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraEntryPoint.kt @@ -0,0 +1,10 @@ +package app.grapheneos.camera.domain.camera.model + +data class CameraEntryPoint( + val isSecureSession: Boolean, + val isCaptureSession: Boolean, + val isVideoOnlySession: Boolean, + val requiresVideoModeOnly: Boolean, + val allowsQrScanning: Boolean, + val showsCameraModeTabs: Boolean, +) diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModes.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModes.kt new file mode 100644 index 000000000..0dd03a988 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModes.kt @@ -0,0 +1,46 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.extensions.ExtensionMode +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository +import app.grapheneos.camera.data.core.model.CameraMode +import javax.inject.Inject + +interface ResolveAvailableModes { + + // Extension verdicts are read straight from the cache because tab refreshes must never pay + // for a vendor probe on the main thread (see loadTabs); an unprobed mode is left out of the + // tabs for now, exactly like a transiently-failed probe always was, and comes back on the + // refresh that follows its probe. + operator fun invoke( + allowsQrScanning: Boolean, + extensionsAvailable: Boolean, + ): Set +} + +internal class ResolveAvailableModesImpl @Inject constructor( + private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, +) : ResolveAvailableModes { + + override fun invoke( + allowsQrScanning: Boolean, + extensionsAvailable: Boolean, + ): Set { + return CameraMode.entries.filter { mode -> + when (mode) { + CameraMode.CAMERA, CameraMode.VIDEO -> true + CameraMode.QR_SCAN -> allowsQrScanning + else -> { + check(mode.extensionMode != ExtensionMode.NONE) + extensionsAvailable && isUsableOnEitherLens(mode.extensionMode) + } + } + }.toSet() + } + + private fun isUsableOnEitherLens(extensionMode: Int): Boolean { + return ExtensionKey.onBothLenses(extensionMode).any { key -> + extensionAvailabilityRepository.verdict(key) == true + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt new file mode 100644 index 000000000..b9b2edc9f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt @@ -0,0 +1,66 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.Quality +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapper +import javax.inject.Inject + +// CameraX resolves a preferred feature group by dropping features until what is left is a +// combination the camera actually supports, and it does so silently. This app only ever asks +// for the video quality and the stabilization that the user selected, so a dropped feature +// means a setting the UI still displays is not in effect. Say so rather than letting the +// recording quietly disagree with the settings screen. +interface ResolveDroppedVideoQuality { + + operator fun invoke( + lensFacing: Int, + requestedQualityFeature: GroupableFeature?, + selected: Set, + ): Quality? +} + +internal class ResolveDroppedVideoQualityImpl @Inject constructor( + private val videoQualityFeatureMapper: VideoQualityFeatureMapper, +) : ResolveDroppedVideoQuality { + + // Avoids repeating an unchanged notice: startCamera() runs again on every tab switch, + // settings change, camera flip and resume, and the outcome is usually the same each time. + // Keyed by lens facing so that alternating between a fully-supported camera and a limited + // one doesn't re-announce the limited camera's unchanged hardware fact on every flip: a + // fully-satisfied bind clears only its own camera's entry, so the next divergence on that + // camera is genuinely new information while the other camera's stays remembered. + private val lastReported = HashMap() + + override fun invoke( + lensFacing: Int, + requestedQualityFeature: GroupableFeature?, + selected: Set, + ): Quality? { + // Only report a dropped quality that can be named: a message that can't say which + // quality it means would be worse than the caller's log line. + val dropped = requestedQualityFeature + ?.takeIf { it !in selected } + ?.let { videoQualityFeatureMapper.map(it) } + + // A dropped quality is the only outcome worth a toast, and it is a genuine one: CameraX + // tries the quality on its own before it tries either stabilization, and the preflight in + // startCamera already gave up in-video snapshots wherever that would let the quality bind, + // so a quality reported dropped here is one this camera cannot record at in the minimal + // configuration either. Stabilization losses are deliberately not surfaced -- besides the + // lead's no-EIS-messaging wish, a "stabilization unsupported" message would misattribute + // the loss, because stabilization can be crowded out by the in-video snapshot stream + // rather than by the quality. + if (dropped == null) { + lastReported.remove(lensFacing) + return null + } + + val alreadyReported = lastReported[lensFacing] == dropped + lastReported[lensFacing] = dropped + + return when { + alreadyReported -> null + else -> dropped + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/gallery/CapturedItemSession.kt b/app/src/main/java/app/grapheneos/camera/domain/gallery/CapturedItemSession.kt new file mode 100644 index 000000000..fc7de5c45 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/gallery/CapturedItemSession.kt @@ -0,0 +1,112 @@ +package app.grapheneos.camera.domain.gallery + +import android.util.Log +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.di.core.MainImmediateDispatcher +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.domain.gallery.mapper.VisibleCaptureMapper +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +interface CapturedItemSession { + + val lastCapturedItem: CapturedItem? + + val storageLocation: String + + suspend fun prepare() + + fun recordCapturedItem(item: CapturedItem) + + fun close() +} + +internal class CapturedItemSessionImpl @Inject constructor( + private val capturedItemRepository: CapturedItemRepository, + private val visibleCaptureMapper: VisibleCaptureMapper, + private val entryPoint: CameraEntryPoint, + @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, +) : CapturedItemSession { + + override var lastCapturedItem: CapturedItem? = runBlocking { restoredCapture() } + private set + + override var storageLocation: String = runBlocking { + capturedItemRepository.storageLocation.first() + } + private set + + private val scope = CoroutineScope(mainDispatcher) + + init { + scope.launch { + capturedItemRepository.storageLocation.collect { + storageLocation = it + } + } + + if (!entryPoint.isSecureSession) { + scope.launch { + capturedItemRepository.lastCapturedItem.collect { + lastCapturedItem = visibleCaptureMapper.map(it) + } + } + } + } + + private suspend fun restoredCapture(): CapturedItem? { + if (entryPoint.isSecureSession) { + return null + } + + val stored = try { + capturedItemRepository.lastCapturedItem.first() + } catch (e: IOException) { + Log.e(TAG, "unable to read the last captured item", e) + null + } + + return visibleCaptureMapper.map(stored) + } + + override suspend fun prepare() { + if (entryPoint.isSecureSession) { + return + } + + // A failed migration leaves the legacy keys for the next launch to retry. + try { + capturedItemRepository.migrateStoredCaptures()?.let { recordCapturedItem(it) } + capturedItemRepository.releaseUntrackedSafTrees() + } catch (e: IOException) { + Log.e(TAG, "unable to migrate the stored captures", e) + } + } + + override fun recordCapturedItem(item: CapturedItem) { + lastCapturedItem = visibleCaptureMapper.map(item) + + scope.launch { + try { + capturedItemRepository.saveLastCapturedItem(item) + } catch (e: IOException) { + Log.e(TAG, "unable to store the last captured item", e) + } + } + } + + override fun close() { + scope.cancel() + } + + private companion object { + private const val TAG = "CapturedItemSession" + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/gallery/mapper/VisibleCaptureMapper.kt b/app/src/main/java/app/grapheneos/camera/domain/gallery/mapper/VisibleCaptureMapper.kt new file mode 100644 index 000000000..d4e2987b4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/gallery/mapper/VisibleCaptureMapper.kt @@ -0,0 +1,22 @@ +package app.grapheneos.camera.domain.gallery.mapper + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_IMAGE +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import javax.inject.Inject + +interface VisibleCaptureMapper { + fun map(item: CapturedItem?): CapturedItem? +} + +internal class VisibleCaptureMapperImpl @Inject constructor( + private val entryPoint: CameraEntryPoint, +) : VisibleCaptureMapper { + + override fun map(item: CapturedItem?): CapturedItem? { + return when { + item?.type == ITEM_TYPE_IMAGE && entryPoint.isVideoOnlySession -> null + else -> item + } + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/gallery/usecase/RevertToMediaStoreLocation.kt b/app/src/main/java/app/grapheneos/camera/domain/gallery/usecase/RevertToMediaStoreLocation.kt new file mode 100644 index 000000000..5b438f561 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/gallery/usecase/RevertToMediaStoreLocation.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.domain.gallery.usecase + +import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import javax.inject.Inject + +interface RevertToMediaStoreLocation { + suspend operator fun invoke() +} + +internal class RevertToMediaStoreLocationImpl @Inject constructor( + private val capturedItemRepository: CapturedItemRepository, +) : RevertToMediaStoreLocation { + + override suspend fun invoke() { + capturedItemRepository.setStorageLocation(CapturedItemRepository.MEDIA_STORE_LOCATION) + capturedItemRepository.releaseUntrackedSafTrees() + } +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt b/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt new file mode 100644 index 000000000..894490e1c --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt @@ -0,0 +1,115 @@ +package app.grapheneos.camera.domain.qr + +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import com.google.zxing.BarcodeFormat +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@ActivityScoped +class BarcodeFormats @Inject constructor( + private val settingsRepository: SettingsRepository, +) { + + private val enabledFormats = arrayListOf() + + val enabled: List + get() { + return enabledFormats + } + + fun load() { + val enabledNames = runBlocking { + settingsRepository.settings.first() + }.enabledBarcodeFormats + + load(enabledNames) + } + + internal fun load(enabledNames: Set) { + enabledFormats.clear() + enabledFormats.addAll(BarcodeFormat.entries.filter { it.name in enabledNames }) + } + + fun uncommonNames(): List { + return BarcodeFormat.entries + .filterNot { it in COMMON_FORMATS } + .map { it.name } + } + + fun isEnabled(formatName: String): Boolean { + return enabledFormats.any { it.name == formatName } + } + + /** Returns false when the change was refused because it would leave nothing to scan. */ + fun setEnabled( + formatName: String, + enabled: Boolean, + ): Boolean { + store(mapOf(formatName to enabled)) + + val format = BarcodeFormat.valueOf(formatName) + + if (enabled) { + if (format !in enabledFormats) { + enabledFormats.add(format) + } + + return true + } + + if (enabledFormats.size == 1) return false + + enabledFormats.remove(format) + + return true + } + + /** Returns false when the selection was refused because it would leave nothing to scan. */ + fun apply(selection: Map): Boolean { + // If all formats displayed outside the dialog are disabled (main QR scanner UI) and no + // option is selected within the check box either - implying no barcode format is selected + // at all - don't apply the selection made by the user + val allCommonFormatsDisabled = COMMON_FORMATS.none { enabledFormats.contains(it) } + + if (allCommonFormatsDisabled && selection.values.none { it }) { + return false + } + + selection.forEach { (formatName, isEnabled) -> + val format = BarcodeFormat.valueOf(formatName) + + when { + !isEnabled -> enabledFormats.remove(format) + format !in enabledFormats -> enabledFormats.add(format) + } + } + + store(selection) + + return true + } + + private fun store(selection: Map) { + runBlocking { + settingsRepository.update { stored -> + selection.entries.fold(stored) { settings, (formatName, isEnabled) -> + settings.withBarcodeFormat( + formatName = formatName, + enabled = isEnabled, + ) + } + } + } + } + + companion object { + val COMMON_FORMATS = listOf( + BarcodeFormat.AZTEC, + BarcodeFormat.QR_CODE, + BarcodeFormat.DATA_MATRIX, + BarcodeFormat.PDF_417, + ) + } +} diff --git a/app/src/main/java/app/grapheneos/camera/notifier/SensorOrientationChangeNotifier.kt b/app/src/main/java/app/grapheneos/camera/notifier/SensorOrientationChangeNotifier.kt index ae16658f8..0304f530b 100644 --- a/app/src/main/java/app/grapheneos/camera/notifier/SensorOrientationChangeNotifier.kt +++ b/app/src/main/java/app/grapheneos/camera/notifier/SensorOrientationChangeNotifier.kt @@ -109,7 +109,7 @@ class SensorOrientationChangeNotifier private constructor( notifyListeners() } - if (!mainActivity.camConfig.shouldShowGyroscope()) { + if (!mainActivity.viewfinder.shouldShowGyroscope()) { mainActivity.gCircleFrame.visibility = View.GONE return } diff --git a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt index 91b4db556..080eae4f0 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/BottomTabLayout.kt @@ -1,8 +1,10 @@ package app.grapheneos.camera.ui import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet +import android.util.Log import android.view.MotionEvent import android.view.ViewGroup import android.view.animation.AnimationUtils @@ -203,6 +205,39 @@ class BottomTabLayout @JvmOverloads constructor( return tabCenters.lastIndex.toFloat() } + @SuppressLint("ClickableViewAccessibility") + fun setModes( + modes: Set, + currentMode: CameraMode, + onTabTouched: (Tab) -> Unit, + ) { + if (modes == getAllModes()) { + return + } + + Log.i(TAG, "Refreshing tabs...") + + removeAllTabs() + + modes.forEach { mode -> + newTab().let { tab -> + tab.setText(cameraModeLabel(mode)) + + tab.view.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_UP) { + onTabTouched(tab) + } + false + } + tab.tag = mode + + // Highlight the mode the camera is really in, not the default one: the tabs are + // also rebuilt long after startup, once the extension probes report back. + addTab(tab, mode == currentMode) + } + } + } + fun getAllModes(): Set { return IntRange(0, tabCount - 1).map { getTabAt(it)!!.tag as CameraMode @@ -210,6 +245,8 @@ class BottomTabLayout @JvmOverloads constructor( } private companion object { + private const val TAG = "BottomTabLayout" + private const val SETTLE_DURATION_MS = 300L } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/CameraModeLabel.kt b/app/src/main/java/app/grapheneos/camera/ui/CameraModeLabel.kt new file mode 100644 index 000000000..9ca6b6e12 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/CameraModeLabel.kt @@ -0,0 +1,19 @@ +package app.grapheneos.camera.ui + +import androidx.annotation.StringRes +import app.grapheneos.camera.R +import app.grapheneos.camera.data.core.model.CameraMode + +@StringRes +fun cameraModeLabel(mode: CameraMode): Int { + return when (mode) { + CameraMode.QR_SCAN -> R.string.qr_scan_mode + CameraMode.AUTO -> R.string.auto_mode + CameraMode.FACE_RETOUCH -> R.string.face_retouch_mode + CameraMode.PORTRAIT -> R.string.portrait_mode + CameraMode.NIGHT -> R.string.night_mode + CameraMode.HDR -> R.string.hdr_mode + CameraMode.CAMERA -> R.string.camera + CameraMode.VIDEO -> R.string.video + } +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt b/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt index 9f5e818c0..0efecec73 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt @@ -8,10 +8,10 @@ import android.view.Gravity import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import androidx.appcompat.widget.AppCompatTextView -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController class CountDownTimerUI @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -19,7 +19,7 @@ class CountDownTimerUI @JvmOverloads constructor( private lateinit var timer: CountDownTimer lateinit var mActivity: MainActivity - lateinit var camConfig: CamConfig + lateinit var viewfinder: ViewfinderController companion object { private const val textAnimDuration = 700L @@ -36,7 +36,7 @@ class CountDownTimerUI @JvmOverloads constructor( fun setMainActivity(mainActivity: MainActivity) { this.mActivity = mainActivity - camConfig = mainActivity.camConfig + viewfinder = mainActivity.viewfinder } fun startTimer() { @@ -68,9 +68,9 @@ class CountDownTimerUI @JvmOverloads constructor( text = pendingS.toString() if (text == "1") { - camConfig.mPlayer.playTimerFinalSSound() + viewfinder.mPlayer?.playTimerFinalSSound() } else { - camConfig.mPlayer.playTimerIncrementSound() + viewfinder.mPlayer?.playTimerIncrementSound() } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt b/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt index 7a65d8c81..b0a35d837 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/CustomGrid.kt @@ -6,9 +6,9 @@ import android.graphics.Color import android.graphics.Paint import android.util.AttributeSet import android.view.View -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.data.settings.model.GridType import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController class CustomGrid @JvmOverloads constructor( context: Context, @@ -31,15 +31,15 @@ class CustomGrid @JvmOverloads constructor( } override fun onDraw(canvas: Canvas) { - val camConfig = mActivity.camConfig + val viewfinder = mActivity.viewfinder super.onDraw(canvas) - if (camConfig.gridType == GridType.NONE) { + if (viewfinder.gridType == GridType.NONE) { return } - if (camConfig.gridType == GridType.GOLDEN_RATIO) { + if (viewfinder.gridType == GridType.GOLDEN_RATIO) { val cx = width / 2f val cy = height / 2f @@ -54,7 +54,7 @@ class CustomGrid @JvmOverloads constructor( } else { - val seed = if (camConfig.gridType == GridType.THREE_BY_THREE) { + val seed = if (viewfinder.gridType == GridType.THREE_BY_THREE) { 3f } else { 4f diff --git a/app/src/main/java/app/grapheneos/camera/ui/QRToggle.kt b/app/src/main/java/app/grapheneos/camera/ui/QRToggle.kt index 4818111ef..da834fc1d 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/QRToggle.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/QRToggle.kt @@ -33,9 +33,9 @@ class QRToggle @JvmOverloads constructor( override fun setSelected(selected: Boolean) { super.setSelected(selected) - val camConfig = mActivity.camConfig + val barcodeFormats = mActivity.barcodeFormats - if (!selected && camConfig.allowedFormats.size == 1) { + if (!selected && barcodeFormats.enabled.size == 1) { // Name the format the way the label under the toggle does, not as the raw enum // constant ("PDF 417", not "PDF_417"). mActivity.showMessage(mActivity.getString( @@ -43,7 +43,11 @@ class QRToggle @JvmOverloads constructor( )) isSelected = true } else { - camConfig.setQRScanningFor(key, selected) + if (!barcodeFormats.setEnabled(formatName = key, enabled = selected)) { + mActivity.showMessage(R.string.no_barcode_selected) + } + + mActivity.session.refreshQrHints() } refreshToggleUI() diff --git a/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt new file mode 100644 index 000000000..4afb192d5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt @@ -0,0 +1,51 @@ +package app.grapheneos.camera.ui + +import app.grapheneos.camera.R +import app.grapheneos.camera.domain.qr.BarcodeFormats +import app.grapheneos.camera.ui.activities.MainActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +fun showMoreQrFormatOptions( + activity: MainActivity, + barcodeFormats: BarcodeFormats, + onApplied: () -> Unit, +) { + val optionNames = barcodeFormats.uncommonNames() + + showQrFormatsDialog( + activity = activity, + optionNames = optionNames, + initialValues = optionNames.map { barcodeFormats.isEnabled(it) }, + onConfirm = { values -> + val selection = optionNames + .withIndex() + .associate { (index, name) -> name to values[index] } + + when { + barcodeFormats.apply(selection) -> onApplied() + else -> activity.showMessage(R.string.no_barcode_selected) + } + }, + ) +} + +private fun showQrFormatsDialog( + activity: MainActivity, + optionNames: List, + initialValues: List, + onConfirm: (List) -> Unit, +) { + val values = initialValues.toBooleanArray() + + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.more_options) + .setMultiChoiceItems(optionNames.toTypedArray(), values) { _, index, isChecked -> + values[index] = isChecked + } + .setPositiveButton(R.string.ok) { _, _ -> + onConfirm(values.toList()) + } + .setNegativeButton(R.string.cancel, null) + .create() + .showIgnoringShortEdgeMode() +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt index e71dd82fc..88983d967 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -41,6 +41,7 @@ import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import app.grapheneos.camera.R import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.focusTimeoutLabel import app.grapheneos.camera.databinding.SettingsBinding import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.ui.activities.MoreSettings @@ -52,7 +53,9 @@ import kotlin.math.max @SuppressLint("ClickableViewAccessibility") class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : Dialog(themedContext) { - val camConfig = mActivity.camConfig + val viewfinder = mActivity.viewfinder + + private val session = mActivity.session private val binding: SettingsBinding by lazy { SettingsBinding.inflate(layoutInflater) } private var dialog: View @@ -175,7 +178,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : getString(R.string.toggle_geo_tagging_unsupported_while_recording) ) } else { - camConfig.requireLocation = locToggle.isChecked + viewfinder.requireLocation = locToggle.isChecked } } @@ -186,27 +189,27 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : getString(R.string.flash_switch_unsupported) ) } else { - camConfig.toggleFlashMode() + viewfinder.toggleFlashMode() } } aRToggle = binding.aspectRatioToggle aRToggle.setOnClickListener { - if (camConfig.isVideoMode) { + if (viewfinder.isVideoMode) { updateAspectRatioToggle(is16by9 = true) mActivity.showMessage( getString(R.string.four_by_three_unsupported_in_video) ) } else { - camConfig.toggleAspectRatio() - updateAspectRatioToggle(camConfig.aspectRatio == AspectRatio.RATIO_16_9) + viewfinder.toggleAspectRatio() + updateAspectRatioToggle(viewfinder.aspectRatio == AspectRatio.RATIO_16_9) } } torchToggle = binding.torchToggleOption torchToggle.setOnClickListener { - if (camConfig.isFlashAvailable) { - camConfig.toggleTorchState() + if (session.isFlashAvailable) { + session.toggleTorchState() } else { torchToggle.isChecked = false mActivity.showMessage( @@ -217,7 +220,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : gridToggle = binding.gridToggleOption gridToggle.setOnClickListener { - camConfig.gridType = when (camConfig.gridType) { + viewfinder.gridType = when (viewfinder.gridType) { GridType.NONE -> GridType.THREE_BY_THREE GridType.THREE_BY_THREE -> GridType.FOUR_BY_FOUR GridType.FOUR_BY_FOUR -> GridType.GOLDEN_RATIO @@ -249,17 +252,17 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } waitForFocusLockSwitch = binding.waitForFocusLockSwitch - waitForFocusLockSwitch.isChecked = camConfig.waitForFocusLock + waitForFocusLockSwitch.isChecked = viewfinder.waitForFocusLock waitForFocusLockSwitch.setOnClickListener { - camConfig.waitForFocusLock = waitForFocusLockSwitch.isChecked - if (camConfig.cameraProvider != null) { - camConfig.startCamera(true) + viewfinder.waitForFocusLock = waitForFocusLockSwitch.isChecked + if (session.cameraProvider != null) { + viewfinder.startCamera(true) } } selfIlluminationToggle = binding.selfIlluminationSwitch selfIlluminationToggle.setOnCheckedChangeListener { _, isChecked -> - camConfig.selfIlluminate = isChecked + viewfinder.selfIlluminate = isChecked } binding.selfIlluminationSwitchContainer.setOnTouchListener { _, event -> event.setLocation(0f, 0f) @@ -363,7 +366,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : View.VISIBLE } - camConfig.includeAudio = includeAudioToggle.isChecked + viewfinder.includeAudio = includeAudioToggle.isChecked } binding.includeAudioSwitchContainer.setOnTouchListener { _, event -> event.setLocation(0f, 0f) @@ -373,8 +376,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : enableEISToggle = binding.enableEisSwitch enableEISToggle.setOnCheckedChangeListener { _, isChecked -> - camConfig.enableEIS = isChecked - camConfig.startCamera(true) + viewfinder.enableEIS = isChecked + viewfinder.startCamera(true) } binding.enableEisSwitchContainer.setOnTouchListener { _, event -> event.setLocation(0f, 0f) @@ -493,10 +496,10 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun showOnlyRelevantSettings() { - if (camConfig.isVideoMode) { + if (viewfinder.isVideoMode) { includeAudioSetting.visibility = View.VISIBLE videoQualitySetting.visibility = View.VISIBLE - enableEISSetting.visibility = if (camConfig.canApplyVideoStabilization()) { + enableEISSetting.visibility = if (session.canApplyVideoStabilization()) { View.VISIBLE } else { View.GONE @@ -508,13 +511,13 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } selfIlluminationSetting.visibility = - if (camConfig.lensFacing == CameraSelector.LENS_FACING_FRONT) { + if (session.lensFacing == CameraSelector.LENS_FACING_FRONT) { View.VISIBLE } else { View.GONE } - timerSetting.visibility = if (camConfig.isVideoMode) { + timerSetting.visibility = if (viewfinder.isVideoMode) { View.GONE } else { View.VISIBLE @@ -525,14 +528,14 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : fun updateFocusTimeout(selectedOption: String) { if (selectedOption == timeOptions[0]) { - camConfig.focusTimeout = 0 + viewfinder.focusTimeout = 0 } else { try { val durS = selectedOption.substring(0, selectedOption.length - 1) val dur = durS.toLong() - camConfig.focusTimeout = dur + viewfinder.focusTimeout = dur } catch (exception: Exception) { @@ -551,11 +554,11 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : mActivity.updateSelfTimerBadge() // Common rather than per-mode: a mode's preferences are not slotted until the camera // starts, which happens after this dialog is built. - camConfig.selfTimerDuration = duration + viewfinder.selfTimerDuration = duration } private fun restoreTimerDuration() { - val duration = camConfig.selfTimerDuration + val duration = viewfinder.selfTimerDuration // Apply directly: Spinner.setSelection() only posts its selection callback, so the duration // would otherwise stay unset for a looper pass. updateTimerDuration(duration) @@ -565,12 +568,12 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun updateVideoQuality(quality: Quality, resCam: Boolean = true) { - if (quality == camConfig.videoQuality) return + if (quality == viewfinder.videoQuality) return - camConfig.videoQuality = quality + viewfinder.videoQuality = quality if (resCam) { - camConfig.startCamera(true) + viewfinder.startCamera(true) } else { videoQualitySpinner.setSelection(videoQualities.indexOf(quality)) } @@ -580,7 +583,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : fun selfIllumination() { - if (camConfig.selfIlluminate) { + if (viewfinder.selfIlluminate) { val colorFrom: Int = Color.BLACK val colorTo: Int = mActivity.getColor(R.color.self_illumination_light) @@ -674,7 +677,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : setBrightness(WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE) } - wasSelfIlluminationOn = camConfig.selfIlluminate + wasSelfIlluminationOn = viewfinder.selfIlluminate } private val slideDownAnimation: Animation by lazy { @@ -758,15 +761,21 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } private fun getAvailableQualities(): List { - val cameraInfo = camConfig.camera?.cameraInfo ?: return Collections.emptyList() + val cameraInfo = session.camera?.cameraInfo ?: return Collections.emptyList() return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } + fun loadInitialState() { + updateGridToggleUI() + updateFocusTimeout(focusTimeoutLabel(viewfinder.focusTimeout)) + enableEISToggle.isChecked = viewfinder.enableEIS + } + fun updateGridToggleUI() { mActivity.previewGrid.postInvalidate() // The description has to travel with the drawable: this control cycles through four // states, so a fixed "Grid Toggle" label left a screen reader unable to report any of them - val (icon, description) = when (camConfig.gridType) { + val (icon, description) = when (viewfinder.gridType) { GridType.NONE -> R.drawable.grid_off_circle to R.string.grid_off GridType.THREE_BY_THREE -> R.drawable.grid_3x3_circle to R.string.grid_3x3 GridType.FOUR_BY_FOUR -> R.drawable.grid_4x4_circle to R.string.grid_4x4 @@ -778,8 +787,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun updateFlashMode() { - val (icon, description) = if (camConfig.isFlashAvailable) { - when (camConfig.flashMode) { + val (icon, description) = if (session.isFlashAvailable) { + when (viewfinder.flashMode) { ImageCapture.FLASH_MODE_ON -> R.drawable.flash_on_circle to R.string.flash_on ImageCapture.FLASH_MODE_AUTO -> R.drawable.flash_auto_circle to R.string.flash_auto else -> R.drawable.flash_off_circle to R.string.flash_off @@ -807,13 +816,13 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : updateFlashMode() - if (camConfig.isVideoMode) { + if (viewfinder.isVideoMode) { updateAspectRatioToggle(is16by9 = true) } else { - updateAspectRatioToggle(camConfig.aspectRatio == AspectRatio.RATIO_16_9) + updateAspectRatioToggle(viewfinder.aspectRatio == AspectRatio.RATIO_16_9) } - torchToggle.isChecked = camConfig.isTorchOn + torchToggle.isChecked = session.isTorchOn updateGridToggleUI() @@ -844,8 +853,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : videoQualitySpinner.adapter = adapter - if (camConfig.videoQuality != Quality.HIGHEST) { - videoQualitySpinner.setSelection(videoQualities.indexOf(camConfig.videoQuality)) + if (viewfinder.videoQuality != Quality.HIGHEST) { + videoQualitySpinner.setSelection(videoQualities.indexOf(viewfinder.videoQuality)) } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/StorageLocationDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/StorageLocationDialog.kt new file mode 100644 index 000000000..4827b34c4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/StorageLocationDialog.kt @@ -0,0 +1,20 @@ +package app.grapheneos.camera.ui + +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.MoreSettings +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +fun showStorageLocationNotFoundDialog(activity: MainActivity) { + val dialog = MaterialAlertDialogBuilder(activity) + .setTitle(R.string.folder_not_found) + .setMessage(R.string.reverting_to_default_folder) + .setPositiveButton(R.string.ok, null) + .setNeutralButton(R.string.more_settings) { _, _ -> + MoreSettings.start(activity) + } + .create() + + dialog.setCancelable(false) + dialog.showIgnoringShortEdgeMode() +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt index a81900b0c..d1f7cd7a8 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt @@ -138,7 +138,7 @@ open class CaptureActivity : MainActivity() { ) previewLoader.visibility = View.VISIBLE - camConfig.imageCapture?.takePicture( + session.imageCapture?.takePicture( ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { @@ -167,7 +167,7 @@ open class CaptureActivity : MainActivity() { open fun showPreview() { - camConfig.cameraProvider?.unbindAll() + session.cameraProvider?.unbindAll() mainOverlay.setImageBitmap(bitmap) mainOverlay.visibility = View.VISIBLE @@ -184,7 +184,7 @@ open class CaptureActivity : MainActivity() { } open fun hidePreview() { - camConfig.startCamera(true) + viewfinder.startCamera(true) settingsIcon.visibility = View.VISIBLE diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 147daf737..8bf21187b 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt @@ -30,12 +30,9 @@ import android.view.GestureDetector import android.view.KeyEvent import android.view.MotionEvent import android.view.PixelCopy -import android.view.ScaleGestureDetector -import android.view.ScaleGestureDetector.OnScaleGestureListener import android.view.Surface import android.view.SurfaceView import android.view.View -import android.view.View.OnTouchListener import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.animation.Animation @@ -63,29 +60,37 @@ import androidx.camera.view.PreviewView.StreamState import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.isVisible import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins +import androidx.lifecycle.lifecycleScope import app.grapheneos.camera.App -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R import app.grapheneos.camera.capturer.ImageCapturer import app.grapheneos.camera.capturer.VideoCapturer import app.grapheneos.camera.capturer.getVideoThumbnail +import app.grapheneos.camera.data.camera.session.CameraSession +import app.grapheneos.camera.data.camera.session.CameraSessionFactory import app.grapheneos.camera.data.core.model.CameraMode -import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.repository.SettingsRepository -import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.databinding.ActivityMainBinding import app.grapheneos.camera.databinding.ScanResultDialogBinding +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.domain.gallery.CapturedItemSession +import app.grapheneos.camera.domain.qr.BarcodeFormats import app.grapheneos.camera.ktx.SystemSettingsObserver import app.grapheneos.camera.ktx.applyPreviewRatio import app.grapheneos.camera.notifier.SensorOrientationChangeNotifier +import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.ui.BottomTabLayout import app.grapheneos.camera.ui.CountDownTimerUI import app.grapheneos.camera.ui.CustomGrid @@ -95,6 +100,11 @@ import app.grapheneos.camera.ui.SettingsDialog import app.grapheneos.camera.ui.seekbar.ExposureBar import app.grapheneos.camera.ui.seekbar.ZoomBar import app.grapheneos.camera.ui.showIgnoringShortEdgeMode +import app.grapheneos.camera.ui.showMoreQrFormatOptions +import app.grapheneos.camera.ui.viewfinder.ViewfinderGestureHandler +import app.grapheneos.camera.ui.viewfinder.ViewfinderOrientationHandler +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderEffectHandler import app.grapheneos.camera.util.CameraControl import app.grapheneos.camera.util.ImageResizer import app.grapheneos.camera.util.executeIfAlive @@ -107,164 +117,195 @@ import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.zxing.BarcodeFormat import dagger.hilt.android.AndroidEntryPoint -import java.io.File import java.nio.charset.StandardCharsets import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt -import androidx.core.graphics.scale -import androidx.core.net.toUri -import androidx.core.view.isVisible -import androidx.core.graphics.createBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @AndroidEntryPoint -open class MainActivity : AppCompatActivity(), - OnTouchListener, - OnScaleGestureListener, - GestureDetector.OnGestureListener, - GestureDetector.OnDoubleTapListener, - SensorOrientationChangeNotifier.Listener { +open class MainActivity : AppCompatActivity() { + + @Inject + lateinit var cameraSessionFactory: CameraSessionFactory + + @Inject + lateinit var viewfinder: ViewfinderController @Inject lateinit var settingsRepository: SettingsRepository @Inject - lateinit var capturedItemRepository: CapturedItemRepository + lateinit var cameraEntryPoint: CameraEntryPoint + + @Inject + lateinit var barcodeFormats: BarcodeFormats + + @Inject + lateinit var capturedItemSession: CapturedItemSession + + lateinit var session: CameraSession private val application: App get() = applicationContext as App - private lateinit var binding: ActivityMainBinding + internal lateinit var binding: ActivityMainBinding - private val cameraPermission = arrayOf(Manifest.permission.CAMERA) + internal val gestureHandler by lazy { ViewfinderGestureHandler(this) } + internal val orientationHandler by lazy { ViewfinderOrientationHandler(this) } - lateinit var previewView: PreviewView - lateinit var previewContainer: ConstraintLayout - lateinit var bottomOverlay: View + val gestureDetector: GestureDetector + get() = gestureHandler.gestureDetector - // Hold a reference to the manual permission dialog to avoid re-creating it if it - // is already visible and to dismiss it if the permission gets granted. - private var cameraPermissionDialog: AlertDialog? = null - private var audioPermissionDialog: AlertDialog? = null + lateinit var imageCapturer: ImageCapturer - @Volatile - var lastFrame: Bitmap? = null - private set + lateinit var videoCapturer: VideoCapturer - @Volatile - private var frameCopyPending = false + lateinit var settingsDialog: SettingsDialog - // When the copy waiting in [lastFrame] was taken, or 0 when there is none waiting. - @Volatile - private var framePrefetchedAt = 0L + val threeButtons: View + get() = binding.threeButtons - private var frameCopyThread: HandlerThread? = null + val previewView: PreviewView + get() = binding.preview - private var loggedMissingSurfaceView = false + val bottomOverlay: View + get() = binding.bottomOverlay - // Whether the transition still is standing in for the preview. - private var transitionShown = false + val rootView: View + get() = binding.root - private lateinit var mainFrame: View - lateinit var rootView: View + val qrScanToggles: View + get() = binding.qrScanToggles - lateinit var qrScanToggles: View - private lateinit var moreOptionsToggle: View + val qrToggle: QRToggle + get() = binding.qrScanToggle - lateinit var qrToggle: QRToggle - lateinit var dmToggle: QRToggle - lateinit var cBToggle: QRToggle - lateinit var azToggle: QRToggle + val dmToggle: QRToggle + get() = binding.dataMatrixToggle - lateinit var imageCapturer: ImageCapturer - lateinit var videoCapturer: VideoCapturer + val cBToggle: QRToggle + get() = binding.pdf417Toggle - lateinit var flipCameraCircle: View - lateinit var cancelButtonView: ImageView - lateinit var tabLayout: BottomTabLayout - lateinit var thirdCircle: ImageView - lateinit var captureButton: ImageButton + val azToggle: QRToggle + get() = binding.aztecToggle - private lateinit var scaleGestureDetector: ScaleGestureDetector - lateinit var timerView: TextView - lateinit var thirdOption: View - lateinit var imagePreview: ShapeableImageView - lateinit var previewLoader: ProgressBar - private var isZooming = false + val flipCameraCircle: View + get() = binding.flipCameraCircle - lateinit var zoomBar: ZoomBar - lateinit var zoomBarPanel: LinearLayout + val cancelButtonView: ImageView + get() = binding.cancelButton - lateinit var exposureBar: ExposureBar - lateinit var exposureBarPanel: LinearLayout + val tabLayout: BottomTabLayout + get() = binding.cameraModeTabs - lateinit var qrOverlay: QROverlay + val captureButton: ImageButton + get() = binding.captureButton - lateinit var threeButtons: LinearLayout + val timerView: TextView + get() = binding.timer - lateinit var settingsIcon: ImageView + val thirdOption: View + get() = binding.thirdOption - private lateinit var exposurePlusIcon: ImageView - private lateinit var exposureNegIcon: ImageView + val imagePreview: ShapeableImageView + get() = binding.imagePreview - private lateinit var zoomInIcon: ImageView - private lateinit var zoomOutIcon: ImageView + val previewLoader: ProgressBar + get() = binding.previewLoading - lateinit var flipCamIcon: ImageView + val zoomBar: ZoomBar + get() = binding.zoomBar - lateinit var mainOverlay: ImageView + val zoomBarPanel: LinearLayout + get() = binding.zoomBarPanel - lateinit var settingsDialog: SettingsDialog - lateinit var previewGrid: CustomGrid + val exposureBar: ExposureBar + get() = binding.exposureBar - private var wasSwiping = false + val exposureBarPanel: LinearLayout + get() = binding.exposureBarPanel - lateinit var cdTimer: CountDownTimerUI - var timerDuration = 0 + val qrOverlay: QROverlay + get() = binding.qrOverlay - lateinit var cbText: TextView - lateinit var cbCross: ImageView + val settingsIcon: ImageView + get() = binding.settingsOption - lateinit var gCircleFrame: FrameLayout + val mainOverlay: ImageView + get() = binding.mainOverlay - private lateinit var gAngleTextView: TextView - private lateinit var gCircle: LinearLayout + val previewGrid: CustomGrid + get() = binding.previewGrid - private lateinit var gLineX: View - private lateinit var gLineZ: View + val cdTimer: CountDownTimerUI + get() = binding.cTimer - private lateinit var gLeftDash: View - private lateinit var gRightDash: View + val cbText: TextView + get() = binding.captureButtonText - lateinit var muteToggle: ShapeableImageView + val cbCross: ImageView + get() = binding.captureButtonCross - private var bottomNavigationBarPadding: Int = 0 + val gCircleFrame: FrameLayout + get() = binding.gCircleFrame - private var shouldGyroVibrate = true - private var hasGyroVibrated = false + val muteToggle: ShapeableImageView + get() = binding.muteToggle - private val gyroVibRunnable = Runnable { - vibrateDevice() - hasGyroVibrated = true - } + val micOffIcon: ImageView + get() = binding.micOff + + private val cameraPermission = arrayOf(Manifest.permission.CAMERA) + + // Hold a reference to the manual permission dialog to avoid re-creating it if it + // is already visible and to dismiss it if the permission gets granted. + private var cameraPermissionDialog: AlertDialog? = null + + private var audioPermissionDialog: AlertDialog? = null + + @Volatile + var lastFrame: Bitmap? = null + private set + + @Volatile + private var frameCopyPending = false + + // When the copy waiting in [lastFrame] was taken, or 0 when there is none waiting. + @Volatile + private var framePrefetchedAt = 0L + + private var frameCopyThread: HandlerThread? = null + + private var loggedMissingSurfaceView = false + + // Whether the transition still is standing in for the preview. + private var transitionShown = false + + var timerDuration = 0 + + private var bottomNavigationBarPadding: Int = 0 + + private var shouldRestartRecording = false val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() private val runnable = Runnable { val factory: MeteringPointFactory = SurfaceOrientedMeteringPointFactory( - previewView.width.toFloat(), previewView.height.toFloat() + previewView.width.toFloat(), + previewView.height.toFloat() ) val autoFocusPoint = factory.createPoint( previewView.width / 2.0f, - previewView.height / 2.0f, QROverlay.RATIO + previewView.height / 2.0f, + QROverlay.RATIO ) - camConfig.camera?.cameraControl?.startFocusAndMetering( + session.camera?.cameraControl?.startFocusAndMetering( FocusMeteringAction.Builder(autoFocusPoint).disableAutoCancel().build() ) @@ -275,23 +316,10 @@ open class MainActivity : AppCompatActivity(), private lateinit var snackBar: Snackbar - private lateinit var focusRing: ImageView - private val focusRingHandler: Handler = Handler(Looper.getMainLooper()) - private val focusRingCallback: Runnable = Runnable { - focusRing.visibility = View.INVISIBLE - } - - lateinit var micOffIcon: ImageView - - private var shouldRestartRecording = false - fun startFocusTimer() { - handler.postDelayed(runnable, autoCenterFocusDuration) - } - - fun cancelFocusTimer() { - handler.removeCallbacks(runnable) + private val focusRingCallback: Runnable = Runnable { + binding.focusRing.visibility = View.INVISIBLE } private val restartRecordingWithAudioPermissionLauncher = registerForActivityResult( @@ -299,7 +327,7 @@ open class MainActivity : AppCompatActivity(), ) { granted -> if (granted) { shouldRestartRecording = true - camConfig.startCamera(true) + viewfinder.startCamera(true) return@registerForActivityResult } showAudioPermissionDeniedDialog { @@ -312,9 +340,7 @@ open class MainActivity : AppCompatActivity(), RequestMultiplePermissions() ) { permissions: Map -> if (permissions.containsKey(Manifest.permission.RECORD_AUDIO)) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED - ) { + if (hasPermission(Manifest.permission.RECORD_AUDIO)) { Log.i(TAG, "Permission granted for recording audio.") } else { Log.i(TAG, "Permission denied for recording audio.") @@ -322,12 +348,7 @@ open class MainActivity : AppCompatActivity(), } } if (permissions.containsKey(Manifest.permission.CAMERA)) { - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.CAMERA - ) == - PackageManager.PERMISSION_GRANTED - ) { + if (hasCameraPermission()) { Log.i(TAG, "Permission granted for camera.") } else { Log.i(TAG, "Permission denied for camera.") @@ -335,22 +356,16 @@ open class MainActivity : AppCompatActivity(), } } - // Used to request permission from the user - var dirPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) - { result -> - - val data: Uri? = result.data?.data + fun onDeviceAngleChange(xDegrees: Float, zDegrees: Float) { + orientationHandler.onDeviceAngleChange(xDegrees, zDegrees) + } - if (data?.encodedPath != null) { - val file = File(data.encodedPath!!) - if (file.exists()) { - showMessage(getString(R.string.file_already_exists, file.absolutePath)) - } else { - showMessage(getString(R.string.file_does_not_exist, data.encodedPath)) - } - } + fun startFocusTimer() { + handler.postDelayed(runnable, autoCenterFocusDuration) + } - Log.i(TAG, "Selected location: ${data?.encodedPath!!}") + fun cancelFocusTimer() { + handler.removeCallbacks(runnable) } private fun showAudioPermissionDeniedDialog(onDisableAudio: () -> Unit = {}) { @@ -363,7 +378,8 @@ open class MainActivity : AppCompatActivity(), val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val uri = Uri.fromParts( "package", - packageName, null + packageName, + null ) intent.data = uri startActivity(intent) @@ -371,7 +387,7 @@ open class MainActivity : AppCompatActivity(), builder.setNegativeButton(R.string.cancel, null) builder.setNeutralButton(R.string.disable_audio) { _: DialogInterface?, _: Int -> - camConfig.includeAudio = false + viewfinder.includeAudio = false onDisableAudio() } @@ -401,7 +417,7 @@ open class MainActivity : AppCompatActivity(), transitionShown = false mainOverlay.visibility = View.INVISIBLE - if (camConfig.isQRMode) { + if (viewfinder.isQRMode) { return } @@ -492,7 +508,10 @@ open class MainActivity : AppCompatActivity(), private fun frameCopyLooper(): Looper { frameCopyThread?.let { return it.looper } - return HandlerThread("frame-copy").apply { start(); frameCopyThread = this }.looper + return HandlerThread("frame-copy").apply { + start() + frameCopyThread = this + }.looper } // Not from the strip's own touch listener: a tab view takes the DOWN, so the strip is only @@ -509,13 +528,12 @@ open class MainActivity : AppCompatActivity(), // A copy taken for a switch that never happened shows a scene the camera has since moved on // from, which is worse behind the transition than paying for a fresh one. private fun hasFreshPrefetch(): Boolean = framePrefetchedAt != 0L && - SystemClock.uptimeMillis() - framePrefetchedAt < PREFETCH_FRESHNESS_MS - - private fun animateFocusRing(x: Float, y: Float) { + SystemClock.uptimeMillis() - framePrefetchedAt < PREFETCH_FRESHNESS_MS + fun animateFocusRing(x: Float, y: Float) { // Move the focus ring so that its center is at the tap location (x, y) - val width = focusRing.width.toFloat() - focusRing.updateLayoutParams { + val width = binding.focusRing.width.toFloat() + binding.focusRing.updateLayoutParams { updateMargins( left = (x - width / 2).roundToInt(), top = (y - width / 2).roundToInt() @@ -523,12 +541,12 @@ open class MainActivity : AppCompatActivity(), } // Show focus ring - focusRing.visibility = View.VISIBLE - focusRing.alpha = 1f + binding.focusRing.visibility = View.VISIBLE + binding.focusRing.alpha = 1f if (areSystemAnimationsEnabled()) { // Animate the focus ring to disappear - focusRing.animate() + binding.focusRing.animate() .setStartDelay(500) .setDuration(300) .alpha(0f) @@ -539,9 +557,8 @@ open class MainActivity : AppCompatActivity(), override fun onAnimationStart(animation: Animator) {} override fun onAnimationEnd(animator: Animator) { - if (!isCancelled) { - focusRing.visibility = View.INVISIBLE + binding.focusRing.visibility = View.INVISIBLE } isCancelled = false @@ -560,15 +577,16 @@ open class MainActivity : AppCompatActivity(), } private fun areSystemAnimationsEnabled(): Boolean { - val duration: Float = Settings.Global.getFloat( contentResolver, - Settings.Global.ANIMATOR_DURATION_SCALE, 1f + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f ) val transition: Float = Settings.Global.getFloat( contentResolver, - Settings.Global.TRANSITION_ANIMATION_SCALE, 1f + Settings.Global.TRANSITION_ANIMATION_SCALE, + 1f ) return duration != 0f && transition != 0f @@ -586,24 +604,32 @@ open class MainActivity : AppCompatActivity(), showMessage(R.string.no_image) return } - it.putParcelableArrayListExtra(InAppGallery.INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS, list) + it.putParcelableArrayListExtra( + InAppGallery.INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS, + list + ) } else { it.putExtra(InAppGallery.INTENT_KEY_VIDEO_ONLY_MODE, requiresVideoModeOnly) } if (isThumbnailLoaded) { // indicates that last captured item is accessible - it.putExtra(InAppGallery.INTENT_KEY_LAST_CAPTURED_ITEM, camConfig.lastCapturedItem) + it.putExtra( + InAppGallery.INTENT_KEY_LAST_CAPTURED_ITEM, + capturedItemSession.lastCapturedItem, + ) } startActivity(it) } + } + private fun hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED } private fun hasCameraPermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED + return hasPermission(Manifest.permission.CAMERA) } private fun checkPermissions() { @@ -612,16 +638,18 @@ open class MainActivity : AppCompatActivity(), // Check if the app has access to the user's camera when { hasCameraPermission() -> { - // If the user has manually granted the permission, dismiss the dialog. - if (cameraPermissionDialog != null && cameraPermissionDialog!!.isShowing) cameraPermissionDialog!!.cancel() + if (cameraPermissionDialog != null && + cameraPermissionDialog!!.isShowing + ) { + cameraPermissionDialog!!.cancel() + } Log.i(TAG, "Permission granted.") // Setup the camera since the permission is available - camConfig.initializeCamera() + viewfinder.initializeCamera() } shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> { - Log.i(TAG, "The user has default denied camera permission.") // Don't build and show a new dialog if it's already visible @@ -637,14 +665,14 @@ open class MainActivity : AppCompatActivity(), val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val uri = Uri.fromParts( "package", - packageName, null + packageName, + null ) intent.data = uri startActivity(intent) } builder.setNegativeButton(R.string.cancel, null) builder.setOnDismissListener { - // The dialog could have either been dismissed by clicking on the // background or by clicking the cancel button. So in those cases, // the app should exit as the app depends on the camera permission. @@ -665,28 +693,23 @@ open class MainActivity : AppCompatActivity(), } audioPermissionDialog?.let { dialog -> - if (ContextCompat.checkSelfPermission( - this, Manifest.permission.RECORD_AUDIO - ) == - PackageManager.PERMISSION_GRANTED - ) { - if (dialog.isShowing) { - dialog.dismiss() - } + if (hasPermission(Manifest.permission.RECORD_AUDIO) && dialog.isShowing) { + dialog.dismiss() } } } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { // there are no camera controls in qr mode - if (camConfig.isQRMode) { + if (viewfinder.isQRMode) { return super.onKeyUp(keyCode, event) } when (keyCode) { KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, - KeyEvent.KEYCODE_CAMERA -> { + KeyEvent.KEYCODE_CAMERA, + -> { captureButton.performClick() } KeyEvent.KEYCODE_FOCUS -> { @@ -714,23 +737,19 @@ open class MainActivity : AppCompatActivity(), override fun onResume() { super.onResume() - resumeOrientationSensor() + orientationHandler.resumeOrientationSensor() // Check camera permission again if the user switches back to the app (maybe // after enabling/disabling the camera permission in Settings) // Will also be called by Android Lifecycle when the app starts up checkPermissions() - if (camConfig.isQRMode) { + if (viewfinder.isQRMode) { startFocusTimer() } - if (this !is SecureActivity) { - camConfig.fetchLastCapturedItem() - } - updateThumbnail() - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { requestLocation() } @@ -738,7 +757,7 @@ open class MainActivity : AppCompatActivity(), if (!(this is VideoCaptureActivity && thirdOption.isVisible)) { if (!isQRDialogShowing) { if (hasCameraPermission()) { - camConfig.initializeCamera(true) + viewfinder.initializeCamera(true) } else { Log.i(TAG, "Leaving the camera uninitialized until the permission is granted.") } @@ -748,61 +767,70 @@ open class MainActivity : AppCompatActivity(), val requiresVideoModeOnly: Boolean get() { - return this is VideoOnlyActivity || this is VideoCaptureActivity + return cameraEntryPoint.requiresVideoModeOnly } + private fun selectBarcodeFormatToggles() { + val toggles = mapOf( + BarcodeFormat.QR_CODE to qrToggle, + BarcodeFormat.AZTEC to azToggle, + BarcodeFormat.PDF_417 to cBToggle, + BarcodeFormat.DATA_MATRIX to dmToggle, + ) + + barcodeFormats.enabled.forEach { format -> + toggles[format]?.isSelected = true + } + } + override fun onPause() { super.onPause() // Leaving a mode switch waiting on an animation that will never finish would strand the // strip on a mode the camera never entered. tabLayout.settleNow() - pauseOrientationSensor() + orientationHandler.pauseOrientationSensor() // The countdown would otherwise keep ticking while the app is in the background and fire a // capture into a camera that has already been unbound. cdTimer.cancelTimer() - if (camConfig.isQRMode) { + if (viewfinder.isQRMode) { cancelFocusTimer() } else { imageCapturer.cancelPendingCaptureRequest() } - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { application.dropLocationUpdates() } lastFrame = null framePrefetchedAt = 0 } - lateinit var gestureDetector: GestureDetector - @SuppressLint("ClickableViewAccessibility") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) snackBar = Snackbar.make(binding.root, "", Snackbar.LENGTH_LONG) - gestureDetector = GestureDetector(this, this) - - camConfig = CamConfig( - mActivity = this, - settingsRepository = settingsRepository, - capturedItemRepository = capturedItemRepository, + val sessionHandler = ViewfinderEffectHandler(this) + session = cameraSessionFactory.create(environment = sessionHandler) + viewfinder.attach( + environment = sessionHandler, + effects = sessionHandler, + chrome = sessionHandler, + session = session, ) - cameraControl = CameraControl(camConfig) - mainOverlay = binding.mainOverlay + cameraControl = CameraControl(session) imageCapturer = ImageCapturer(this) videoCapturer = VideoCapturer(this) - thirdOption = binding.thirdOption - previewLoader = binding.previewLoading - imagePreview = binding.imagePreview - previewView = binding.preview - previewView.scaleType = PreviewView.ScaleType.FIT_START - previewContainer = binding.previewContainer - bottomOverlay = binding.bottomOverlay - scaleGestureDetector = ScaleGestureDetector(this, this) - tabLayout = binding.cameraModeTabs + lifecycleScope.launch(Dispatchers.Main.immediate) { + capturedItemSession.prepare() + + updateThumbnail() + } + + previewView.scaleType = PreviewView.ScaleType.FIT_START tabLayout.setOnTouchListener { _, motionEvent -> if (motionEvent.action == MotionEvent.ACTION_UP) { @@ -814,20 +842,16 @@ open class MainActivity : AppCompatActivity(), return@setOnTouchListener false } - timerView = binding.timer previewView.previewStreamState.observe(this) { state: StreamState -> if (state == StreamState.STREAMING) { hidePreviewTransition() - camConfig.reloadSettings() + viewfinder.reloadSettings() restartRecordingIfPermissionsWasUnavailable() } else { showPreviewTransition() } } - flipCameraCircle = binding.flipCameraCircle - - flipCamIcon = binding.flipCameraIconContent var tapDownTimestamp: Long = 0 flipCameraCircle.setOnTouchListener { _, event -> @@ -857,8 +881,8 @@ open class MainActivity : AppCompatActivity(), } flipCameraCircle.setOnClickListener { resetAutoSleep() - if (camConfig.isQRMode) { - camConfig.scanAllCodes = !camConfig.scanAllCodes + if (viewfinder.isQRMode) { + viewfinder.scanAllCodes = !viewfinder.scanAllCodes return@setOnClickListener } @@ -886,10 +910,10 @@ open class MainActivity : AppCompatActivity(), rotate.interpolator = LinearInterpolator() it.startAnimation(rotate) - camConfig.toggleCameraSelector() + viewfinder.toggleCameraSelector() } - thirdCircle = binding.thirdCircle - thirdCircle.setOnClickListener { + + binding.thirdCircle.setOnClickListener { resetAutoSleep() if (videoCapturer.isRecording) { imageCapturer.takePicture() @@ -899,18 +923,16 @@ open class MainActivity : AppCompatActivity(), } } - thirdCircle.setOnLongClickListener { + binding.thirdCircle.setOnLongClickListener { if (videoCapturer.isRecording) { imageCapturer.takePicture() } else { shareLatestMedia() - } return@setOnLongClickListener true } - captureButton = binding.captureButton captureButton.setOnClickListener { resetAutoSleep() @@ -918,15 +940,15 @@ open class MainActivity : AppCompatActivity(), // would otherwise capture in the mode being left behind. tabLayout.settleNow() - if (camConfig.isVideoMode) { + if (viewfinder.isVideoMode) { if (videoCapturer.isRecording) { videoCapturer.stopRecording() } else { videoCapturer.startRecording() } - } else if (camConfig.isQRMode) { - camConfig.toggleTorchState() - if (camConfig.isTorchOn) { + } else if (viewfinder.isQRMode) { + session.toggleTorchState() + if (session.isTorchOn) { setCaptureButtonIcon(R.drawable.torch_on_button, R.string.turn_torch_off) } else { setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) @@ -944,75 +966,54 @@ open class MainActivity : AppCompatActivity(), } } - cancelButtonView = binding.cancelButton - - zoomBar = binding.zoomBar zoomBar.setMainActivity(this) - - zoomBarPanel = binding.zoomBarPanel - - exposureBar = binding.exposureBar exposureBar.setMainActivity(this) - exposureBarPanel = binding.exposureBarPanel - - qrOverlay = binding.qrOverlay - - threeButtons = binding.threeButtons - settingsIcon = binding.settingsOption settingsIcon.setOnClickListener { - if (!camConfig.isQRMode) + if (!viewfinder.isQRMode) { settingsDialog.show() + } } - settingsIcon.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - rootView.viewTreeObserver.removeOnGlobalLayoutListener(this) - val displayCutout = window.decorView.rootWindowInsets.displayCutout - val layoutParams = (settingsIcon.layoutParams as RelativeLayout.LayoutParams) - - val rect = if (displayCutout?.boundingRects?.isNotEmpty() == true) - displayCutout.boundingRects.first() else null - - val windowsSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds - } else { - val size = Point() - // defaultDisplay isn't deprecated below API 30 as highlighted by the IDE - // and this code would only execute if it is (Hint: enclosing if-block) - @Suppress("DEPRECATION") - windowManager.defaultDisplay.getRealSize(size) - Rect(0, 0, size.x, size.y) - } + settingsIcon.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + rootView.viewTreeObserver.removeOnGlobalLayoutListener(this) + val displayCutout = window.decorView.rootWindowInsets.displayCutout + val layoutParams = (settingsIcon.layoutParams as RelativeLayout.LayoutParams) - if (rect == null || rect.left <= 0 || rect.right == windowsSize.right) { - layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL) - } else { - layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT) - } + val rect = if (displayCutout?.boundingRects?.isNotEmpty() == true) { + displayCutout.boundingRects.first() + } else { + null } - }) - exposurePlusIcon = binding.exposurePlusIcon - exposureNegIcon = binding.exposureNegIcon + val windowsSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowManager.currentWindowMetrics.bounds + } else { + val size = Point() + // defaultDisplay isn't deprecated below API 30 as highlighted by the IDE + // and this code would only execute if it is (Hint: enclosing if-block) + @Suppress("DEPRECATION") + windowManager.defaultDisplay.getRealSize(size) + Rect(0, 0, size.x, size.y) + } - zoomInIcon = binding.zoomInIcon - zoomOutIcon = binding.zoomOutIcon + if (rect == null || rect.left <= 0 || rect.right == windowsSize.right) { + layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL) + } else { + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + } + } + }) - previewGrid = binding.previewGrid previewGrid.setMainActivity(this) - rootView = binding.root - - mainFrame = binding.mainFrame - - qrScanToggles = binding.qrScanToggles - var isInsetSet = false ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, windowInsets -> - val insets = windowInsets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) + val insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.systemBars() + ) view.layoutParams = (view.layoutParams as ViewGroup.MarginLayoutParams).let { it.setMargins( @@ -1030,8 +1031,8 @@ open class MainActivity : AppCompatActivity(), bottomNavigationBarPadding = insets.bottom if (insets.top != 0 && !isInsetSet) { - mainFrame.layoutParams = - (mainFrame.layoutParams as ViewGroup.MarginLayoutParams).let { + binding.mainFrame.layoutParams = + (binding.mainFrame.layoutParams as ViewGroup.MarginLayoutParams).let { it.setMargins( it.leftMargin, (8 * resources.displayMetrics.density.toInt()) + insets.top, @@ -1047,7 +1048,7 @@ open class MainActivity : AppCompatActivity(), it.setMargins( it.leftMargin, (16 * resources.displayMetrics.density.toInt()) + - insets.top, + insets.top, it.rightMargin, it.bottomMargin, ) @@ -1071,23 +1072,15 @@ open class MainActivity : AppCompatActivity(), enableEdgeToEdge() - cdTimer = binding.cTimer cdTimer.setMainActivity(this) - cbText = binding.captureButtonText - cbCross = binding.captureButtonCross - val themedContext = DynamicColors.wrapContextIfAvailable(this, R.style.Theme_SettingsDialog) settingsDialog = SettingsDialog(this, themedContext) - SystemSettingsObserver(lifecycle,Settings.System.ACCELEROMETER_ROTATION,this) { + SystemSettingsObserver(lifecycle, Settings.System.ACCELEROMETER_ROTATION, this) { forceUpdateOrientationSensor() } - focusRing = binding.focusRing - - micOffIcon = binding.micOff - previewView.viewTreeObserver.addOnPreDrawListener( object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { @@ -1098,41 +1091,34 @@ open class MainActivity : AppCompatActivity(), } ) - moreOptionsToggle = binding.moreOptions - moreOptionsToggle.setOnClickListener { - camConfig.showMoreOptionsForQR() + binding.moreOptions.setOnClickListener { + showMoreQrFormatOptions( + activity = this, + barcodeFormats = barcodeFormats, + onApplied = { session.refreshQrHints() }, + ) } - qrToggle = binding.qrScanToggle qrToggle.mActivity = this qrToggle.key = BarcodeFormat.QR_CODE.name - dmToggle = binding.dataMatrixToggle dmToggle.mActivity = this dmToggle.key = BarcodeFormat.DATA_MATRIX.name - cBToggle = binding.pdf417Toggle cBToggle.mActivity = this cBToggle.key = BarcodeFormat.PDF_417.name - azToggle = binding.aztecToggle azToggle.mActivity = this azToggle.key = BarcodeFormat.AZTEC.name - camConfig.loadSettings() - - gCircle = binding.gCircle - gAngleTextView = binding.gCircleText + viewfinder.loadSettings() - gLineX = binding.gCircleLineX - gLineZ = binding.gCircleLineZ + barcodeFormats.load() + selectBarcodeFormatToggles() + session.refreshQrHints() - gLeftDash = binding.gCircleLeftDash - gRightDash = binding.gCircleRightDash + settingsDialog.loadInitialState() - gCircleFrame = binding.gCircleFrame - - muteToggle = binding.muteToggle muteToggle.setOnClickListener { if (videoCapturer.isMuted) { videoCapturer.unmuteRecording() @@ -1147,60 +1133,55 @@ open class MainActivity : AppCompatActivity(), } private fun repositionTabLayout() { - threeButtons.visibility = View.VISIBLE - tabLayout.viewTreeObserver.addOnPreDrawListener( - object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { + tabLayout.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + tabLayout.viewTreeObserver + .removeOnPreDrawListener( + this + ) - tabLayout.viewTreeObserver - .removeOnPreDrawListener( - this - ) + val previewHeight169 = binding.previewContainer.width * 16 / 9 - val previewHeight169 = previewContainer.width * 16 / 9 - - val extraHeight169 = previewContainer.height - - previewHeight169 - - tabLayout.height - - 10 * resources.displayMetrics.density.toInt() - - // When there's no extra space in 16:9 for even the bottom nav bar to be present without - // obscuring the preview or if there's sufficient space for the entire bottom UI to exist - val shouldSnapAboveBottomNav = extraHeight169 < bottomNavigationBarPadding - || extraHeight169 >= (threeButtons.height + tabLayout.height + tabLayout.marginTop) - - tabLayout.layoutParams = - (tabLayout.layoutParams as ViewGroup.MarginLayoutParams).let { - - it.setMargins( - it.leftMargin, - it.topMargin, - it.rightMargin, - if (shouldSnapAboveBottomNav) { - bottomNavigationBarPadding - } else { - extraHeight169 - } - ) - - it - } + val extraHeight169 = binding.previewContainer.height - + previewHeight169 - + tabLayout.height - + 10 * resources.displayMetrics.density.toInt() - return true - } + // When there's no extra space in 16:9 for even the bottom nav bar to be present without + // obscuring the preview or if there's sufficient space for the entire bottom UI to exist + val shouldSnapAboveBottomNav = extraHeight169 < bottomNavigationBarPadding || + extraHeight169 >= + (threeButtons.height + tabLayout.height + tabLayout.marginTop) - }) + tabLayout.layoutParams = + (tabLayout.layoutParams as ViewGroup.MarginLayoutParams).let { + it.setMargins( + it.leftMargin, + it.topMargin, + it.rightMargin, + if (shouldSnapAboveBottomNav) { + bottomNavigationBarPadding + } else { + extraHeight169 + } + ) + + it + } + + return true + } + }) } fun finalizeMode(tab: TabLayout.Tab? = null) { - // The strip is untouchable during a recording but not while its start sound still plays, and // rebinding the camera there starts the queued recording on a dead recorder. The touch may // already have dragged the strip, so put it back on the mode the camera is really in. if (videoCapturer.isRecording) { - tabLayout.getTabForMode(camConfig.currentMode)?.let { + tabLayout.getTabForMode(viewfinder.currentMode)?.let { tabLayout.goToTab(it) } return @@ -1214,15 +1195,15 @@ open class MainActivity : AppCompatActivity(), // holds the main thread for half a second, so a transition left to the stream state // would only reach the screen after the wait it is there to explain. Guarded on the // mode really changing, since nothing would rebind to take it back down again. - if (mode != camConfig.currentMode) { + if (mode != viewfinder.currentMode) { showPreviewTransition() } // switchMode() puts the strip on the mode the camera actually ended up in, which is a // different one when an extension fails to bind. tabLayout.goToTab(selectedTab) { - if (mode != camConfig.currentMode) { - camConfig.switchMode(mode) + if (mode != viewfinder.currentMode) { + viewfinder.switchMode(mode) } else if ( transitionShown && previewView.previewStreamState.value == StreamState.STREAMING @@ -1241,7 +1222,7 @@ open class MainActivity : AppCompatActivity(), fun updateSelfTimerBadge() { cbText.text = if (timerDuration == 0) "" else "${timerDuration}s" // isVideoMode covers the video-only activities too, whatever mode they are nominally in. - val applies = timerDuration != 0 && !camConfig.isQRMode && !camConfig.isVideoMode + val applies = timerDuration != 0 && !viewfinder.isQRMode && !viewfinder.isVideoMode cbText.visibility = if (applies) View.VISIBLE else View.INVISIBLE } @@ -1255,7 +1236,7 @@ open class MainActivity : AppCompatActivity(), return } - val item = camConfig.lastCapturedItem + val item = capturedItemSession.lastCapturedItem if (item == null) { showMessage(R.string.please_wait_for_image_to_get_captured_before_sharing) return @@ -1301,7 +1282,6 @@ open class MainActivity : AppCompatActivity(), private var isQRDialogShowing = false fun onScanResultSuccess(rawText: String) { - if (isQRDialogShowing) return isQRDialogShowing = true @@ -1351,13 +1331,17 @@ open class MainActivity : AppCompatActivity(), override fun onTabUnselected(tab: TabLayout.Tab?) {} }) - tabLayout.addTab(tabLayout.newTab().apply { - text = "UTF-8" - }) + tabLayout.addTab( + tabLayout.newTab().apply { + text = "UTF-8" + } + ) - tabLayout.addTab(tabLayout.newTab().apply { - text = "Binary" - }) + tabLayout.addTab( + tabLayout.newTab().apply { + text = "Binary" + } + ) val ctc: ImageButton = dialogBinding.copyQrText ctc.setOnClickListener { @@ -1388,290 +1372,24 @@ open class MainActivity : AppCompatActivity(), builder.setOnDismissListener { isQRDialogShowing = false - camConfig.startCamera(true) + viewfinder.startCamera(true) } - camConfig.cameraProvider?.unbindAll() + session.cameraProvider?.unbindAll() builder.showIgnoringShortEdgeMode() } } - override fun onTouch(v: View, event: MotionEvent): Boolean { - scaleGestureDetector.onTouchEvent(event) - gestureDetector.onTouchEvent(event) - - if (event.action == MotionEvent.ACTION_DOWN) return true else if (event.action == MotionEvent.ACTION_UP) { - - if (wasSwiping) { - wasSwiping = false - return wasSwiping - } - - if (isZooming) { - isZooming = false - return true - } - - if (camConfig.isQRMode) - return false - - val x = event.x - val y = event.y - - val autoFocusPoint = previewView.meteringPointFactory.createPoint(x, y) - animateFocusRing(x, y) - - val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint) - - if (!camConfig.isVideoMode) { - camConfig.mPlayer.playFocusStartSound() - } - - if (camConfig.focusTimeout == 0L) { - focusBuilder.disableAutoCancel() - } else { - focusBuilder.setAutoCancelDuration(camConfig.focusTimeout, TimeUnit.SECONDS) - } - - camConfig.camera!!.cameraControl.startFocusAndMetering(focusBuilder.build()) - - exposureBar.showPanel() - zoomBar.showPanel() - return v.performClick() - } - return true - } - - override fun onScale(detector: ScaleGestureDetector): Boolean { - isZooming = true - val zoomState = camConfig.zoomState - var scale = 1f - if (zoomState != null) { - scale = zoomState.zoomRatio * detector.scaleFactor - } - camConfig.camera!!.cameraControl.setZoomRatio(scale) - return true - } - - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) {} - - private fun rotateView(view: View?, angle: Float) { - if (view != null) { - view.animate().cancel() - - // Ensuring that the rotation seems continuous - if (view.rotation == 0f && angle == 270f) - view.rotation = 360f - - if (view.rotation == 270f && angle == 0f) - view.rotation = -90f - - view.animate() - .rotation(angle) - .setDuration(400) - .setInterpolator(LinearInterpolator()) - .start() - } - } - - @SuppressLint("RestrictedApi") - override fun onOrientationChange(orientation: Int) { - - val tr = when (orientation) { - in 45..134 -> Surface.ROTATION_270 - in 135..224 -> Surface.ROTATION_180 - in 225..314 -> Surface.ROTATION_90 - else -> Surface.ROTATION_0 - } - - camConfig.imageCapture?.targetRotation = tr - camConfig.videoCapture?.targetRotation = tr - camConfig.iAnalyzer?.targetRotation = tr - - if (videoCapturer.isRecording) return - - var iconRotation = (360f - ((orientation - getRotation() + 360) % 360)) % 360 - - // Rotate views that should rotate irrespective of the auto-rotate setting - rotateView(gCircleFrame, iconRotation) - - // Set iconRotation to 0 - if (Settings.System.getInt( - contentResolver, - Settings.System.ACCELEROMETER_ROTATION, 0 - ) != 1 - ) { - iconRotation = 0f - } - - // Rotate views that shouldn't be affected by the auto rotate setting - // (Rotates back to 0 when the auto rotate gets toggled to off when the app - // is running) - rotateView(flipCameraCircle, iconRotation) - rotateView(cancelButtonView, iconRotation) - rotateView(thirdOption, iconRotation) - - rotateView(exposurePlusIcon, iconRotation) - rotateView(exposureNegIcon, iconRotation) - rotateView(zoomInIcon, iconRotation) - rotateView(zoomOutIcon, iconRotation) - rotateView(settingsDialog.settingsFrame, iconRotation) - - rotateView(micOffIcon, iconRotation) - rotateView(muteToggle, iconRotation) - } - - lateinit var camConfig: CamConfig - private lateinit var cameraControl: CameraControl - - companion object { - private const val TAG = "GOCam" - private const val autoCenterFocusDuration = 2000L - private val hexArray = "0123456789ABCDEF".toCharArray() - - private const val SWIPE_THRESHOLD = 100 - private const val SWIPE_VELOCITY_THRESHOLD = 100 - - private const val GYRO_VIBE_WAIT_TIME = 250L - - private const val PREFETCH_FRESHNESS_MS = 2_000L - - // One preview frame at 30fps, the wait for the camera to fill the surface again. - private const val FRAME_COPY_RETRY_DELAY_MS = 33L - private const val FRAME_COPY_RETRIES = 3 - } - - override fun onDown(e: MotionEvent): Boolean { - return false - } - - override fun onShowPress(e: MotionEvent) {} - - override fun onSingleTapUp(e: MotionEvent): Boolean { - return false - } - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - return false - } - - override fun onLongPress(e: MotionEvent) {} - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - - var result = false - try { - e1 ?: return false - - val diffY = e2.y - e1.y - val diffX = e2.x - e1.x - - if (abs(diffX) > abs(diffY)) { - if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { - if (diffX > 0) { - onSwipeRight() - } else { - onSwipeLeft() - } - result = true - } - } else if (abs(diffY) > SWIPE_THRESHOLD && abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { - if (diffY > 0) { - onSwipeBottom() - } else { - onSwipeTop() - } - result = true - } - } catch (exception: Exception) { - exception.printStackTrace() - } - return result - } - - private fun onSwipeBottom() { - if (isZooming || cdTimer.isRunning) return - wasSwiping = true - if (settingsDialog.isShowing) return - - if (camConfig.isQRMode) { - if (!camConfig.scanAllCodes) { - camConfig.showMoreOptionsForQR() - } - } else { - if (settingsIcon.isEnabled) { - settingsIcon.performClick() - } - } - } - - private fun onSwipeRight() { - - if (isZooming || cdTimer.isRunning || videoCapturer.isRecording) - return - - if (this is VideoOnlyActivity) return - - wasSwiping = true - if (settingsDialog.isShowing) return - - - val i = tabLayout.selectedTabPosition - 1 - - Log.i(TAG, "onSwipeRight $i") - tabLayout.getTabAt(i)?.let { - finalizeMode(it) - } - } - - private fun onSwipeTop() { - if (isZooming || cdTimer.isRunning || videoCapturer.isRecording) return - wasSwiping = true - settingsDialog.slideDialogUp() - } - - private fun onSwipeLeft() { - if (isZooming || cdTimer.isRunning || videoCapturer.isRecording) return - - if (this is VideoOnlyActivity) return - - wasSwiping = true - if (settingsDialog.isShowing) return - - val i = tabLayout.selectedTabPosition + 1 - tabLayout.getTabAt(i)?.let { - finalizeMode(it) - } - } - - override fun onSingleTapConfirmed(p0: MotionEvent): Boolean { - return false - } - - override fun onDoubleTap(p0: MotionEvent): Boolean { - return false + fun showMessage(@StringRes message: Int) { + showMessage(getString(message)) } - override fun onDoubleTapEvent(p0: MotionEvent): Boolean { - return false - } - - fun showMessage(@StringRes msg: Int, action: String? = null, callback: View.OnClickListener? = null) { + fun showMessage( + @StringRes msg: Int, + action: String?, + callback: View.OnClickListener?, + ) { showMessage(getString(msg), action, callback) } @@ -1682,8 +1400,8 @@ open class MainActivity : AppCompatActivity(), * button. */ fun setFlipCameraIcon(@DrawableRes icon: Int, @StringRes description: Int) { - flipCamIcon.setImageResource(icon) - flipCamIcon.contentDescription = getString(description) + binding.flipCameraIconContent.setImageResource(icon) + binding.flipCameraIconContent.contentDescription = getString(description) } /** @@ -1697,13 +1415,13 @@ open class MainActivity : AppCompatActivity(), } /** - * [thirdCircle] is the view that carries the click listener, so it is the one that has to be + * `thirdCircle` is the view that carries the click listener, so it is the one that has to be * described: it opens the gallery, except while a video is being recorded, when it takes a * still instead. */ fun setThirdCircleIcon(@DrawableRes icon: Int, @StringRes description: Int) { - thirdCircle.setImageResource(icon) - thirdCircle.contentDescription = getString(description) + binding.thirdCircle.setImageResource(icon) + binding.thirdCircle.contentDescription = getString(description) } /** @@ -1724,7 +1442,11 @@ open class MainActivity : AppCompatActivity(), ) } - fun showMessage(msg: String, action: String? = null, callback: View.OnClickListener? = null) { + fun showMessage(message: String) { + showMessage(message, action = null, callback = null) + } + + fun showMessage(msg: String, action: String?, callback: View.OnClickListener?) { snackBar.apply { setText(msg) setAction(action, callback) @@ -1732,9 +1454,9 @@ open class MainActivity : AppCompatActivity(), } } - fun indicateLocationProvidedIsDisabled() { - showMessage(getString(R.string.location_is_disabled), + showMessage( + getString(R.string.location_is_disabled), if (this !is SecureMainActivity) getString(R.string.enable) else null ) { enableLocationLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) @@ -1748,23 +1470,15 @@ open class MainActivity : AppCompatActivity(), // rotation-dependent state. // The preview follows the window; the capture use cases follow the sensor and are updated // by onOrientationChange. - camConfig.preview?.targetRotation = previewView.display?.rotation ?: Surface.ROTATION_0 - camConfig.camera?.cameraInfo?.let { - previewView.applyPreviewRatio(camConfig.aspectRatio, it) + session.preview?.targetRotation = + previewView.display?.rotation ?: Surface.ROTATION_0 + session.camera?.cameraInfo?.let { + previewView.applyPreviewRatio(viewfinder.aspectRatio, it) } rootView.post { sensorNotifier?.notifyListeners() } } - private fun pauseOrientationSensor() { - SensorOrientationChangeNotifier - .getInstance(this)?.remove(this) - } - - private fun resumeOrientationSensor() { - sensorNotifier?.addListener(this) - } - fun forceUpdateOrientationSensor() { sensorNotifier?.notifyListeners(true) } @@ -1776,8 +1490,10 @@ open class MainActivity : AppCompatActivity(), fun getRotation(): Int { val rotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - display?.rotation ?: @Suppress("DEPRECATION") - windowManager.defaultDisplay.rotation + display?.rotation + ?: + @Suppress("DEPRECATION") + windowManager.defaultDisplay.rotation } else { @Suppress("DEPRECATION") windowManager.defaultDisplay.rotation @@ -1791,115 +1507,11 @@ open class MainActivity : AppCompatActivity(), } } - fun onDeviceAngleChange(xDegrees: Float, zDegrees: Float) { - - val reverseDirection = sensorNotifier?.mOrientation == 270 || sensorNotifier?.mOrientation == 180 - - val xAngle = if (reverseDirection) { - -xDegrees - } else { - xDegrees - } - - val zAngle = zDegrees - - // If we are in photo mode and the countdown timer isn't running - if (!(camConfig.isQRMode || camConfig.isVideoMode || cdTimer.isRunning)) { - - if (gCircle.rotation != xAngle) { - gCircle.rotation = xAngle - gLineZ.rotation = xAngle - - val absXAngle = abs(xAngle).toInt() - - gAngleTextView.text = getString(R.string.degree_format, absXAngle) - if (xAngle == 0f) { - setThicknessOfGLines(4) - if (shouldGyroVibrate) { - shouldGyroVibrate = false - hasGyroVibrated = false - handler.postDelayed(gyroVibRunnable, GYRO_VIBE_WAIT_TIME) - } - } else { - handler.removeCallbacks(gyroVibRunnable) - if (!hasGyroVibrated || absXAngle > 5) { - shouldGyroVibrate = true - } - setThicknessOfGLines(2) - } - } - - Log.i(TAG, "zAngle: $zAngle") - - val lzAngle = when { - zAngle < -45 -> { - -45 - } - zAngle > 45 -> { - 45 - } - else -> { - zAngle - } - }.toFloat() - - if (zAngle.toInt() == 0) { - gLineX.setBackgroundResource(R.drawable.yellow_shadow_rect) - gLineZ.visibility = View.GONE - - gLeftDash.setBackgroundResource(R.drawable.yellow_shadow_rect) - gRightDash.setBackgroundResource(R.drawable.yellow_shadow_rect) - - gAngleTextView.setTextColor(ContextCompat.getColor(this, R.color.z_yellow)) - - } else { - gLineX.setBackgroundResource(R.drawable.white_shadow_rect) - gLineZ.visibility = View.VISIBLE - - gLeftDash.setBackgroundResource(R.drawable.white_shadow_rect) - gRightDash.setBackgroundResource(R.drawable.white_shadow_rect) - - gAngleTextView.setTextColor(ContextCompat.getColor(this, android.R.color.white)) - } - - val zOffset = (lzAngle / 60) * dp32 - - gLineZ.layoutParams = (gLineZ.layoutParams as ViewGroup.MarginLayoutParams).let { - it.setMargins( - it.leftMargin, - it.topMargin, - it.rightMargin, - zOffset.toInt(), - ) - it - } - } - } - - private val dp32 by lazy { + internal val dp32 by lazy { 32 * resources.displayMetrics.density } - private fun setThicknessOfGLines(dp: Int) { - val t = dp * resources.displayMetrics.density - - gLeftDash.layoutParams = gLeftDash.layoutParams.let { - it.height = t.toInt() - it - } - - gRightDash.layoutParams = gRightDash.layoutParams.let { - it.height = t.toInt() - it - } - - gLineX.layoutParams = gLineX.layoutParams.let { - it.height = t.toInt() - it - } - } - - private fun vibrateDevice() { + internal fun vibrateDevice() { val vibrator = getSystemService(Vibrator::class.java) vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) } @@ -1909,10 +1521,11 @@ open class MainActivity : AppCompatActivity(), SensorOrientationChangeNotifier.clearInstance() thumbnailLoaderExecutor.shutdownNow() frameCopyThread?.quitSafely() - camConfig.onDestroy() + viewfinder.detach() + capturedItemSession.close() } - fun locationCamConfigChanged(required: Boolean) { + fun onRequireLocationChanged(required: Boolean) { if (required) { requestLocation() } else { @@ -1925,7 +1538,7 @@ open class MainActivity : AppCompatActivity(), ) { // The snackbar that leads here outlives a mode switch, so geo-tagging can be off for the // mode this returns to - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { requestLocation(application.isAnyLocationProvideActive()) } } @@ -1937,17 +1550,19 @@ open class MainActivity : AppCompatActivity(), if (!application.shouldAskForLocationPermission()) { requestLocation() } else { - camConfig.requireLocation = false + viewfinder.requireLocation = false } } + @SuppressLint("MissingPermission") private fun requestLocation(reAttach: Boolean = false) { - when { ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.ACCESS_FINE_LOCATION + this, + Manifest.permission.ACCESS_FINE_LOCATION ) && ActivityCompat.shouldShowRequestPermissionRationale( - this, Manifest.permission.ACCESS_COARSE_LOCATION + this, + Manifest.permission.ACCESS_COARSE_LOCATION ) -> { MaterialAlertDialogBuilder(this).let { it.setTitle(R.string.location_permission_dialog_title) @@ -1962,23 +1577,15 @@ open class MainActivity : AppCompatActivity(), } it.setOnDismissListener { - if (ContextCompat.checkSelfPermission( - this, Manifest.permission.ACCESS_COARSE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) { - camConfig.requireLocation = false + if (!hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { + viewfinder.requireLocation = false } } }.showIgnoringShortEdgeMode() } - (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED - || ActivityCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - == PackageManager.PERMISSION_GRANTED) -> { + hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) || + hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION) -> { application.requestLocationUpdates(reAttach) } else -> { @@ -2016,7 +1623,7 @@ open class MainActivity : AppCompatActivity(), var isThumbnailLoaded = false fun updateThumbnail() { - val item = camConfig.lastCapturedItem + val item = capturedItemSession.lastCapturedItem val preview = imagePreview preview.setImageBitmap(null) isThumbnailLoaded = false @@ -2052,7 +1659,7 @@ open class MainActivity : AppCompatActivity(), if (bitmap != null) { mainExecutor.execute { - if (isStarted && camConfig.lastCapturedItem == item) { + if (isStarted && capturedItemSession.lastCapturedItem == item) { preview.setImageBitmap(bitmap) isThumbnailLoaded = true } @@ -2069,4 +1676,18 @@ open class MainActivity : AppCompatActivity(), videoCapturer.startRecording() } } + + private lateinit var cameraControl: CameraControl + + companion object { + private const val TAG = "GOCam" + private const val autoCenterFocusDuration = 2000L + private val hexArray = "0123456789ABCDEF".toCharArray() + + private const val PREFETCH_FRESHNESS_MS = 2_000L + + // One preview frame at 30fps, the wait for the camera to fill the surface again. + private const val FRAME_COPY_RETRY_DELAY_MS = 33L + private const val FRAME_COPY_RETRIES = 3 + } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt index a8c969af4..8232234ed 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt @@ -2,7 +2,6 @@ package app.grapheneos.camera.ui.activities import android.content.Context import android.content.Intent -import android.graphics.Color import android.graphics.Rect import android.os.Bundle import android.view.KeyEvent @@ -16,23 +15,42 @@ import android.widget.TextView import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.CapturedItems import app.grapheneos.camera.NumInputFilter import app.grapheneos.camera.R import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.databinding.MoreSettingsBinding import app.grapheneos.camera.util.storageLocationToUiString import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking -open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { - private lateinit var camConfig: CamConfig +@AndroidEntryPoint +open class MoreSettings : + AppCompatActivity(), + TextView.OnEditorActionListener { + + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var capturedItemRepository: CapturedItemRepository + + private var isInCaptureMode = false + + private var isZslSupported = false + + private var storageLocation: String = CapturedItemRepository.MEDIA_STORE_LOCATION private lateinit var binding: MoreSettingsBinding + private lateinit var snackBar: Snackbar private lateinit var sLField: EditText @@ -43,6 +61,11 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { private lateinit var pQField: EditText + private val settings: CameraSettings + get() { + return settingsRepository.settings.value + } + private val dirPickerHandler = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { @@ -55,33 +78,43 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } } if (uri != null) { - contentResolver.takePersistableUriPermission(uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) val uriString = uri.toString() - camConfig.storageLocation = uriString + setStorageLocation(uriString) val uiString = storageLocationToUiString(this, uriString) sLField.setText(uiString) showMessage(getString(R.string.storage_location_updated, uiString)) - } else { showMessage(getString(R.string.no_directory_selected)) } } + private fun updateSettings(transform: (CameraSettings) -> CameraSettings) { + runBlocking { settingsRepository.update(transform) } + } + + private fun setStorageLocation(location: String) { + runBlocking { + capturedItemRepository.setStorageLocation(location) + capturedItemRepository.releaseUntrackedSafTrees() + } + + storageLocation = location + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val camConfig = obtainCamConfig(intent) - if (camConfig == null) { - finish() - return - } - this.camConfig = camConfig + isInCaptureMode = intent.getBooleanExtra(INTENT_EXTRA_IN_CAPTURE_MODE, false) + isZslSupported = intent.getBooleanExtra(INTENT_EXTRA_ZSL_SUPPORTED, false) + storageLocation = runBlocking { capturedItemRepository.storageLocation.first() } binding = MoreSettingsBinding.inflate(layoutInflater) setContentView(binding.root) @@ -90,37 +123,37 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { val sIAPToggle = binding.saveImageAsPreviewToggle - sIAPToggle.isChecked = camConfig.saveImageAsPreviewed + sIAPToggle.isChecked = settings.saveImageAsPreviewed sIAPToggle.setOnClickListener { - camConfig.saveImageAsPreviewed = - sIAPToggle.isChecked + updateSettings { it.copy(saveImageAsPreviewed = sIAPToggle.isChecked) } } val sVAPToggle = binding.saveVideoAsPreviewToggle - sVAPToggle.isChecked = camConfig.saveVideoAsPreviewed + sVAPToggle.isChecked = settings.saveVideoAsPreviewed sVAPToggle.setOnClickListener { - camConfig.saveVideoAsPreviewed = sVAPToggle.isChecked + updateSettings { it.copy(saveVideoAsPreviewed = sVAPToggle.isChecked) } } rootView = binding.rootView sLField = binding.storageLocationField - sLField.setText(storageLocationToUiString(this, camConfig.storageLocation)) + sLField.setText(storageLocationToUiString(this, storageLocation)) sLField.setOnClickListener { val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - dirPickerHandler.launch(Intent.createChooser(i, getString(R.string.choose_storage_location))) + dirPickerHandler.launch( + Intent.createChooser(i, getString(R.string.choose_storage_location)) + ) } snackBar = Snackbar.make(rootView, "", Snackbar.LENGTH_LONG) rSLocation = binding.refreshStorageLocation rSLocation.setOnClickListener { - val dialog = MaterialAlertDialogBuilder(this) dialog.setTitle(R.string.are_you_sure) @@ -130,9 +163,9 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { dialog.setPositiveButton(R.string.yes) { _, _ -> val defaultLocation = CapturedItemRepository.MEDIA_STORE_LOCATION - if (camConfig.storageLocation != defaultLocation) { + if (storageLocation != defaultLocation) { showMessage(getString(R.string.reverted_to_default_directory)) - camConfig.storageLocation = defaultLocation + setStorageLocation(defaultLocation) sLField.setText(storageLocationToUiString(this, defaultLocation)) } else { showMessage(getString(R.string.already_using_default_directory)) @@ -145,7 +178,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { pQField = binding.photoQuality - pQField.setText(camConfig.photoQuality.toString()) + pQField.setText(settings.photoQuality.toString()) pQField.filters = arrayOf(NumInputFilter(this)) pQField.setOnEditorActionListener(this) @@ -154,7 +187,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { val exifToggleSetting = binding.removeExifSetting exifToggleSetting.setOnClickListener { - if (camConfig.isInCaptureMode) { + if (isInCaptureMode) { showMessage( getString(R.string.image_taken_in_this_mode_does_not_contain_extra_data) ) @@ -164,21 +197,21 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } // Lock toggle in checked state in capture mode - if (camConfig.isInCaptureMode) { + if (isInCaptureMode) { exifToggle.isChecked = true exifToggle.isEnabled = false } else { - exifToggle.isChecked = camConfig.removeExifAfterCapture + exifToggle.isChecked = settings.removeExifAfterCapture } exifToggle.setOnClickListener { - camConfig.removeExifAfterCapture = exifToggle.isChecked + updateSettings { it.copy(removeExifAfterCapture = exifToggle.isChecked) } } val gSwitch = binding.gyroscopeSettingSwitch - gSwitch.isChecked = camConfig.gSuggestions + gSwitch.isChecked = settings.gyroscopeSuggestions gSwitch.setOnClickListener { - camConfig.gSuggestions = gSwitch.isChecked + updateSettings { it.copy(gyroscopeSuggestions = gSwitch.isChecked) } } val gSetting = binding.gyroscopeSetting @@ -187,9 +220,9 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } val csSwitch = binding.cameraSoundsSwitch - csSwitch.isChecked = camConfig.enableCameraSounds + csSwitch.isChecked = settings.enableCameraSounds csSwitch.setOnClickListener { - camConfig.enableCameraSounds = csSwitch.isChecked + updateSettings { it.copy(enableCameraSounds = csSwitch.isChecked) } } val csSetting = binding.cameraSoundsSetting @@ -218,18 +251,18 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { pQSetting.setOnClickListener { pQField.requestFocus() pQField.setSelection(pQField.text.length) - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(pQField, 0) } val zslSetting = binding.zslSetting - if (camConfig.isZslSupported) { + if (isZslSupported) { zslSetting.visibility = View.VISIBLE val zslToggle = binding.zslSettingToggle - zslToggle.isChecked = camConfig.enableZsl + zslToggle.isChecked = settings.enableZsl zslToggle.setOnClickListener { - camConfig.enableZsl = !camConfig.enableZsl + updateSettings { it.copy(enableZsl = !settings.enableZsl) } } zslSetting.setOnClickListener { @@ -239,10 +272,10 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { val highResSetting = binding.highestResSetting val highResToggle = binding.highestResSettingToggle - highResToggle.isChecked = camConfig.selectHighestResolution + highResToggle.isChecked = settings.selectHighestResolution highResToggle.setOnClickListener { - camConfig.selectHighestResolution = !camConfig.selectHighestResolution + updateSettings { it.copy(selectHighestResolution = !settings.selectHighestResolution) } } highResSetting.setOnClickListener { @@ -272,7 +305,6 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_UP) { val v: View? = currentFocus if (v is EditText) { @@ -303,7 +335,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { // the up arrow, Home, task switch) used to drop whatever had been typed, silently. // Commit here so that every exit path persists; a snackbar would be pointless on a // screen that is going away, so the invalid-value complaint is suppressed. - // onCreate() can bail out before the views exist (no CamConfig in the intent) and the + // onCreate() can bail out before the views exist (no ViewfinderController in the intent) and the // lifecycle still runs through onPause, hence the initialization check. if (this::pQField.isInitialized) { dumpData(notifyOnInvalidValue = false) @@ -312,7 +344,6 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } private fun dumpData(notifyOnInvalidValue: Boolean = true) { - // Dump state of photo quality val quality = pQField.text.toString().toIntOrNull() // NumInputFilter keeps out-of-range values from being typed, but it cannot stop them being @@ -321,12 +352,12 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { // that made ImageCapture reject the quality and crash the next bind. if (quality == null || quality !in NumInputFilter.min..NumInputFilter.max) { // Revert back to the original value if invalid number was found - pQField.setText(camConfig.photoQuality.toString()) + pQField.setText(settings.photoQuality.toString()) if (notifyOnInvalidValue) { showMessage(getString(R.string.invalid_photo_quality_value)) } } else { - camConfig.photoQuality = quality + updateSettings { it.copy(photoQuality = quality) } } } @@ -335,7 +366,9 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { clearFocus() dumpData() true - } else false + } else { + false + } } fun showMessage(msg: String) { @@ -349,36 +382,18 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } companion object { - private var camConfigId = 0L - private var staticCamConfig: CamConfig? = null - private const val INTENT_EXTRA_CAM_CONFIG_ID = "camConfig_id" + private const val INTENT_EXTRA_IN_CAPTURE_MODE = "in_capture_mode" + private const val INTENT_EXTRA_ZSL_SUPPORTED = "zsl_supported" fun start(caller: MainActivity) { val flavor = if (caller is SecureActivity) MoreSettingsSecure::class else MoreSettings::class Intent(caller, flavor.java).let { - camConfigId += 1 - it.putExtra(INTENT_EXTRA_CAM_CONFIG_ID, camConfigId) - staticCamConfig = caller.camConfig + it.putExtra(INTENT_EXTRA_IN_CAPTURE_MODE, caller.cameraEntryPoint.isCaptureSession) + it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.session.isZslSupported) caller.startActivity(it) } } - - private fun obtainCamConfig(intent: Intent): CamConfig? { - val camConfig = staticCamConfig - if (camConfigId != intent.getLongExtra(INTENT_EXTRA_CAM_CONFIG_ID, -1)) { - return null - } - return camConfig - } - } - - override fun onDestroy() { - super.onDestroy() - - if (isFinishing) { - staticCamConfig = null - } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt index 5843750de..2330b964c 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/QrTile.kt @@ -15,7 +15,7 @@ class QrTile : SecureMainActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - camConfig.switchMode(CameraMode.QR_SCAN) + viewfinder.switchMode(CameraMode.QR_SCAN) } override fun shouldShowCameraModeTabs() = false diff --git a/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureBar.kt b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureBar.kt index 1b22d4d20..1f17d726d 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureBar.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureBar.kt @@ -112,7 +112,7 @@ class ExposureBar : AppCompatSeekBar { Log.i("progress", progress.toString()) Log.i("max", max.toString()) - mainActivity.camConfig.camera?.cameraControl + mainActivity.session.camera?.cameraControl ?.setExposureCompensationIndex(progress) showPanel() diff --git a/app/src/main/java/app/grapheneos/camera/ui/seekbar/ZoomBar.kt b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ZoomBar.kt index 74c08b075..168e8d172 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/seekbar/ZoomBar.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ZoomBar.kt @@ -18,9 +18,10 @@ import androidx.camera.core.ZoomState import androidx.transition.Fade import androidx.transition.Transition import androidx.transition.TransitionManager -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R +import app.grapheneos.camera.data.camera.session.CameraSession import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController import kotlin.math.roundToInt class ZoomBar : AppCompatSeekBar { @@ -46,11 +47,14 @@ class ZoomBar : AppCompatSeekBar { .inflate(R.layout.zoom_bar_thumb, null, false) private lateinit var mainActivity: MainActivity - private lateinit var camConfig: CamConfig + private lateinit var viewfinder: ViewfinderController + + private lateinit var session: CameraSession fun setMainActivity(mainActivity: MainActivity) { this.mainActivity = mainActivity - camConfig = mainActivity.camConfig + viewfinder = mainActivity.viewfinder + session = mainActivity.session } fun showPanel() { @@ -85,7 +89,7 @@ class ZoomBar : AppCompatSeekBar { } fun updateThumb(shouldShowPanel: Boolean = true) { - val zoomState: ZoomState? = camConfig.zoomState + val zoomState: ZoomState? = session.zoomState if (shouldShowPanel) { showPanel() @@ -147,7 +151,7 @@ class ZoomBar : AppCompatSeekBar { if (progress < 1) progress = 1 if (progress > 100) progress = 100 - camConfig.camera?.cameraControl?.setLinearZoom(progress / 100f) + session.camera?.cameraControl?.setLinearZoom(progress / 100f) } MotionEvent.ACTION_CANCEL -> { diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt new file mode 100644 index 000000000..74544b788 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -0,0 +1,253 @@ +package app.grapheneos.camera.ui.viewfinder + +import android.util.Log +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import androidx.camera.core.FocusMeteringAction +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.VideoOnlyActivity +import app.grapheneos.camera.ui.showMoreQrFormatOptions +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +internal class ViewfinderGestureHandler( + private val activity: MainActivity, +) : View.OnTouchListener, + ScaleGestureDetector.OnScaleGestureListener, + GestureDetector.OnGestureListener, + GestureDetector.OnDoubleTapListener { + + val gestureDetector = GestureDetector(activity, this) + + private val scaleGestureDetector = ScaleGestureDetector(activity, this) + + private var isZooming = false + + private var wasSwiping = false + + override fun onTouch(v: View, event: MotionEvent): Boolean { + scaleGestureDetector.onTouchEvent(event) + gestureDetector.onTouchEvent(event) + + if (event.action != MotionEvent.ACTION_UP) return true + + if (wasSwiping) { + wasSwiping = false + return false + } + + if (isZooming) { + isZooming = false + return true + } + + if (activity.viewfinder.isQRMode) { + return false + } + + val x = event.x + val y = event.y + + val camera = activity.session.camera ?: return true + + val autoFocusPoint = activity.previewView.meteringPointFactory.createPoint(x, y) + activity.animateFocusRing(x, y) + + val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint) + + if (!activity.viewfinder.isVideoMode) { + activity.viewfinder.mPlayer?.playFocusStartSound() + } + + if (activity.viewfinder.focusTimeout == 0L) { + focusBuilder.disableAutoCancel() + } else { + focusBuilder.setAutoCancelDuration( + activity.viewfinder.focusTimeout, + TimeUnit.SECONDS, + ) + } + + camera.cameraControl.startFocusAndMetering(focusBuilder.build()) + + activity.exposureBar.showPanel() + activity.zoomBar.showPanel() + + return v.performClick() + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + isZooming = true + + val zoomState = activity.session.zoomState + var scale = 1f + + if (zoomState != null) { + scale = zoomState.zoomRatio * detector.scaleFactor + } + + val camera = activity.session.camera ?: return true + camera.cameraControl.setZoomRatio(scale) + + return true + } + + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) {} + + override fun onDown(e: MotionEvent): Boolean { + return false + } + + override fun onShowPress(e: MotionEvent) {} + + override fun onSingleTapUp(e: MotionEvent): Boolean { + return false + } + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + return false + } + + override fun onLongPress(e: MotionEvent) {} + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + e1 ?: return false + + val diffX = e2.x - e1.x + val diffY = e2.y - e1.y + + return try { + when { + abs(diffX) > abs(diffY) -> onHorizontalFling(diffX, velocityX) + else -> onVerticalFling(diffY, velocityY) + } + } catch (exception: Exception) { + exception.printStackTrace() + false + } + } + + private fun onHorizontalFling(distance: Float, velocity: Float): Boolean { + if (!isSwipe(distance, velocity)) return false + + when { + distance > 0 -> onSwipeRight() + else -> onSwipeLeft() + } + + return true + } + + private fun onVerticalFling(distance: Float, velocity: Float): Boolean { + if (!isSwipe(distance, velocity)) return false + + when { + distance > 0 -> onSwipeBottom() + else -> onSwipeTop() + } + + return true + } + + private fun isSwipe(distance: Float, velocity: Float): Boolean { + return abs(distance) > SWIPE_THRESHOLD && abs(velocity) > SWIPE_VELOCITY_THRESHOLD + } + + private fun onSwipeBottom() { + if (isZooming || activity.cdTimer.isRunning) return + + wasSwiping = true + + if (activity.settingsDialog.isShowing) return + + when { + !activity.viewfinder.isQRMode -> { + if (activity.settingsIcon.isEnabled) { + activity.settingsIcon.performClick() + } + } + + !activity.viewfinder.scanAllCodes -> { + showMoreQrFormatOptions( + activity = activity, + barcodeFormats = activity.barcodeFormats, + onApplied = { activity.session.refreshQrHints() }, + ) + } + } + } + + private fun onSwipeRight() { + if (isZooming || activity.cdTimer.isRunning || activity.videoCapturer.isRecording) return + + if (activity is VideoOnlyActivity) return + + wasSwiping = true + + if (activity.settingsDialog.isShowing) return + + val i = activity.tabLayout.selectedTabPosition - 1 + + Log.i(TAG, "onSwipeRight $i") + + activity.tabLayout.getTabAt(i)?.let { + activity.finalizeMode(it) + } + } + + private fun onSwipeTop() { + if (isZooming || activity.cdTimer.isRunning || activity.videoCapturer.isRecording) return + + wasSwiping = true + activity.settingsDialog.slideDialogUp() + } + + private fun onSwipeLeft() { + if (isZooming || activity.cdTimer.isRunning || activity.videoCapturer.isRecording) return + + if (activity is VideoOnlyActivity) return + + wasSwiping = true + + if (activity.settingsDialog.isShowing) return + + val i = activity.tabLayout.selectedTabPosition + 1 + activity.tabLayout.getTabAt(i)?.let { + activity.finalizeMode(it) + } + } + + override fun onSingleTapConfirmed(p0: MotionEvent): Boolean { + return false + } + + override fun onDoubleTap(p0: MotionEvent): Boolean { + return false + } + + override fun onDoubleTapEvent(p0: MotionEvent): Boolean { + return false + } + + private companion object { + private const val TAG = "ViewfinderGesture" + private const val SWIPE_THRESHOLD = 100 + private const val SWIPE_VELOCITY_THRESHOLD = 100 + } +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt new file mode 100644 index 000000000..6a8981e2e --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt @@ -0,0 +1,217 @@ +package app.grapheneos.camera.ui.viewfinder + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import app.grapheneos.camera.R +import app.grapheneos.camera.notifier.SensorOrientationChangeNotifier +import app.grapheneos.camera.ui.activities.MainActivity +import kotlin.math.abs + +internal class ViewfinderOrientationHandler( + private val activity: MainActivity, +) : SensorOrientationChangeNotifier.Listener { + + private val handler = Handler(Looper.getMainLooper()) + + private var shouldGyroVibrate = true + + private var hasGyroVibrated = false + + private val gyroVibRunnable = Runnable { + activity.vibrateDevice() + hasGyroVibrated = true + } + + private fun rotateView(view: View?, angle: Float) { + if (view != null) { + view.animate().cancel() + + // Ensuring that the rotation seems continuous + if (view.rotation == 0f && angle == 270f) { + view.rotation = 360f + } + + if (view.rotation == 270f && angle == 0f) { + view.rotation = -90f + } + + view.animate() + .rotation(angle) + .setDuration(400) + .setInterpolator(LinearInterpolator()) + .start() + } + } + + @SuppressLint("RestrictedApi") + override fun onOrientationChange(orientation: Int) { + val targetRotation = when (orientation) { + in 45..134 -> Surface.ROTATION_270 + in 135..224 -> Surface.ROTATION_180 + in 225..314 -> Surface.ROTATION_90 + else -> Surface.ROTATION_0 + } + + activity.session.imageCapture?.targetRotation = targetRotation + activity.session.videoCapture?.targetRotation = targetRotation + activity.session.iAnalyzer?.targetRotation = targetRotation + + if (activity.videoCapturer.isRecording) return + + var iconRotation = (360f - ((orientation - activity.getRotation() + 360) % 360)) % 360 + + // Rotate views that should rotate irrespective of the auto-rotate setting + rotateView(activity.gCircleFrame, iconRotation) + + val autoRotate = Settings.System.getInt( + activity.contentResolver, + Settings.System.ACCELEROMETER_ROTATION, + 0, + ) + + // Set iconRotation to 0 + if (autoRotate != 1) { + iconRotation = 0f + } + + // Rotate views that shouldn't be affected by the auto rotate setting + // (Rotates back to 0 when the auto rotate gets toggled to off when the app + // is running) + rotateView(activity.flipCameraCircle, iconRotation) + rotateView(activity.cancelButtonView, iconRotation) + rotateView(activity.thirdOption, iconRotation) + + rotateView(activity.binding.exposurePlusIcon, iconRotation) + rotateView(activity.binding.exposureNegIcon, iconRotation) + rotateView(activity.binding.zoomInIcon, iconRotation) + rotateView(activity.binding.zoomOutIcon, iconRotation) + rotateView(activity.settingsDialog.settingsFrame, iconRotation) + + rotateView(activity.micOffIcon, iconRotation) + rotateView(activity.muteToggle, iconRotation) + } + + fun pauseOrientationSensor() { + SensorOrientationChangeNotifier.getInstance(activity)?.remove(this) + } + + fun resumeOrientationSensor() { + activity.sensorNotifier?.addListener(this) + } + + fun onDeviceAngleChange( + xDegrees: Float, + zDegrees: Float, + ) { + // If we are in photo mode and the countdown timer isn't running + if (activity.viewfinder.isQRMode || activity.viewfinder.isVideoMode || + activity.cdTimer.isRunning + ) { + return + } + + val reverseDirection = activity.sensorNotifier?.mOrientation == 270 || + activity.sensorNotifier?.mOrientation == 180 + + val xAngle = when { + reverseDirection -> -xDegrees + else -> xDegrees + } + + updateTiltIndicator(xAngle) + updateLevelLine(zDegrees) + } + + private fun updateTiltIndicator(xAngle: Float) { + if (activity.binding.gCircle.rotation == xAngle) return + + activity.binding.gCircle.rotation = xAngle + activity.binding.gCircleLineZ.rotation = xAngle + + val absXAngle = abs(xAngle).toInt() + + activity.binding.gCircleText.text = activity.getString(R.string.degree_format, absXAngle) + + if (xAngle == 0f) { + setThicknessOfGLines(4) + + if (shouldGyroVibrate) { + shouldGyroVibrate = false + hasGyroVibrated = false + handler.postDelayed(gyroVibRunnable, GYRO_VIBE_WAIT_TIME) + } + } else { + handler.removeCallbacks(gyroVibRunnable) + + if (!hasGyroVibrated || absXAngle > 5) { + shouldGyroVibrate = true + } + + setThicknessOfGLines(2) + } + } + + private fun updateLevelLine(zDegrees: Float) { + Log.i(TAG, "zAngle: $zDegrees") + + val isLevel = zDegrees.toInt() == 0 + + val lineBackground = when { + isLevel -> R.drawable.yellow_shadow_rect + else -> R.drawable.white_shadow_rect + } + val textColor = when { + isLevel -> R.color.z_yellow + else -> android.R.color.white + } + + activity.binding.gCircleLineX.setBackgroundResource(lineBackground) + activity.binding.gCircleLeftDash.setBackgroundResource(lineBackground) + activity.binding.gCircleRightDash.setBackgroundResource(lineBackground) + + activity.binding.gCircleText.setTextColor(ContextCompat.getColor(activity, textColor)) + + activity.binding.gCircleLineZ.visibility = when { + isLevel -> View.GONE + else -> View.VISIBLE + } + + val clampedZAngle = when { + zDegrees < -45 -> -45 + zDegrees > 45 -> 45 + else -> zDegrees + }.toFloat() + + val zOffset = (clampedZAngle / 60) * activity.dp32 + + activity.binding.gCircleLineZ.updateLayoutParams { + bottomMargin = zOffset.toInt() + } + } + + private fun setThicknessOfGLines(dp: Int) { + val thickness = (dp * activity.resources.displayMetrics.density).toInt() + + listOf( + activity.binding.gCircleLeftDash, + activity.binding.gCircleRightDash, + activity.binding.gCircleLineX, + ).forEach { line -> + line.updateLayoutParams { height = thickness } + } + } + + private companion object { + private const val TAG = "ViewfinderOrientation" + private const val GYRO_VIBE_WAIT_TIME = 250L + } +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt new file mode 100644 index 000000000..6b5066ed7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt @@ -0,0 +1,44 @@ +package app.grapheneos.camera.ui.viewfinder.screen + +import androidx.camera.core.CameraInfo +import androidx.camera.core.ExposureState +import app.grapheneos.camera.data.core.model.CameraMode + +interface ViewfinderChrome { + + fun applyModeChrome(mode: CameraMode, isVideoMode: Boolean, scanAllCodes: Boolean) + + fun applyScanAllCodesChrome(scanAllCodes: Boolean) + + fun setCameraModeTabs(modes: Set, currentMode: CameraMode) + + fun goToModeTab(mode: CameraMode) + + fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) + + fun updateZoomThumb(shouldShowPanel: Boolean) + + fun applyExposureState(exposureState: ExposureState) + + fun hideExposurePanel() + + fun setMicMutedIconVisible(visible: Boolean) + + fun updateGyroscopeIndicator(inPhotoMode: Boolean) + + fun onFlashModeChanged() + + fun onIncludeAudioChanged(enabled: Boolean) + + fun onGeoTaggingChanged(enabled: Boolean) + + fun onSelfIlluminationChanged(enabled: Boolean) + + fun onRequireLocationChanged(required: Boolean) + + fun reloadVideoQualities() + + fun showOnlyRelevantSettings() + + fun resetTorchToggle() +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt new file mode 100644 index 000000000..5186cabc5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt @@ -0,0 +1,609 @@ +package app.grapheneos.camera.ui.viewfinder.screen + +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.camera.core.AspectRatio +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.Quality +import app.grapheneos.camera.R +import app.grapheneos.camera.TunePlayer +import app.grapheneos.camera.data.camera.model.BindOutcome +import app.grapheneos.camera.data.camera.model.CameraBindSettings +import app.grapheneos.camera.data.camera.session.CameraSession +import app.grapheneos.camera.data.camera.session.CameraSessionEnvironment +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.GridType +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.ModeSlot +import app.grapheneos.camera.data.settings.model.SettingsDefaults +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality +import app.grapheneos.camera.domain.gallery.usecase.RevertToMediaStoreLocation +import dagger.hilt.android.scopes.ActivityScoped +import java.io.IOException +import javax.inject.Inject +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty +import kotlinx.coroutines.runBlocking + +@ActivityScoped +class ViewfinderController @Inject constructor( + private val entryPoint: CameraEntryPoint, + private val settingsRepository: SettingsRepository, + private val resolveAvailableModes: ResolveAvailableModes, + private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, + private val revertToMediaStoreLocation: RevertToMediaStoreLocation, +) : CameraSession.Listener { + + private var attachment: Attachment? = null + + @set:VisibleForTesting + var mPlayer: TunePlayer? = null + + private val attached: Attachment + get() { + return requireNotNull(attachment) { + "No Activity is attached to the viewfinder" + } + } + + private val environment: CameraSessionEnvironment + get() { + return attached.environment + } + + private val effects: ViewfinderEffects + get() { + return attached.effects + } + + private val chrome: ViewfinderChrome + get() { + return attached.chrome + } + + private val session: CameraSession + get() { + return attached.session + } + + private val settings: CameraSettings + get() { + return settingsRepository.settings.value + } + + private var modeSettings: ModeSettings = ModeSettings() + + private var slot: ModeSlot? = null + + var currentMode: CameraMode = DEFAULT_CAMERA_MODE + private set + + val isQRMode: Boolean + get() { + return currentMode.isQr + } + + val isVideoMode: Boolean + get() { + return currentMode.isVideo || entryPoint.requiresVideoModeOnly + } + + val canTakePicture: Boolean + get() { + return session.imageCapture != null + } + + private val isInPhotoMode: Boolean + get() { + return !(isQRMode || isVideoMode) + } + + var gridType: GridType by setting( + read = { it.gridType }, + write = { current, value -> current.copy(gridType = value) }, + ) + + var focusTimeout: Long by setting( + read = { it.focusTimeoutSeconds }, + write = { current, value -> current.copy(focusTimeoutSeconds = value) }, + ) + + var selfTimerDuration: Int by setting( + read = { it.selfTimerDurationSeconds }, + write = { current, value -> current.copy(selfTimerDurationSeconds = value) }, + ) + + var enableCameraSounds: Boolean by setting( + read = { it.enableCameraSounds }, + write = { current, value -> current.copy(enableCameraSounds = value) }, + ) + + var enableEIS: Boolean by setting( + read = { it.enableEis }, + write = { current, value -> current.copy(enableEis = value) }, + ) + + var saveImageAsPreviewed: Boolean by setting( + read = { it.saveImageAsPreviewed }, + write = { current, value -> current.copy(saveImageAsPreviewed = value) }, + ) + + var photoQuality: Int by setting( + read = { it.photoQuality }, + write = { current, value -> current.copy(photoQuality = value) }, + ) + + var removeExifAfterCapture: Boolean by setting( + read = { it.removeExifAfterCapture }, + write = { current, value -> current.copy(removeExifAfterCapture = value) }, + ) + + var waitForFocusLock: Boolean by setting( + read = { it.waitForFocusLock }, + write = { current, value -> current.copy(waitForFocusLock = value) }, + ) + + var aspectRatio: Int + get() { + return when { + isVideoMode -> AspectRatio.RATIO_16_9 + isQRMode -> AspectRatio.RATIO_4_3 + else -> settings.aspectRatio + } + } + set(value) { + runBlocking { settingsRepository.update { it.copy(aspectRatio = value) } } + } + + var scanAllCodes: Boolean by setting( + read = { it.scanAllCodes }, + write = { current, value -> current.copy(scanAllCodes = value) }, + onChanged = { value -> + if (isQRMode) { + chrome.applyScanAllCodesChrome(value) + } + + session.refreshQrHints() + }, + ) + + var includeAudio: Boolean by setting( + read = { it.includeAudio }, + write = { current, value -> current.copy(includeAudio = value) }, + onChanged = { value -> chrome.onIncludeAudioChanged(value) }, + ) + + var flashMode: Int = SettingsDefaults.FLASH_MODE + private set + + var videoQuality: Quality + get() = modeSettings.videoQuality + set(value) { + writeMode { slot -> settingsRepository.setVideoQuality(slot, value) } + } + + // Session state rather than the stored value: geo-tagging is only ever on once the permission + // is actually granted, and reloadSettings() is what settles a stored "on" against that. Reading + // the preference back here would resurrect the very stale "on" the coercion exists to drop. + var requireLocation: Boolean = false + set(value) { + chrome.onRequireLocationChanged(value) + + // A permission result is delivered before the first onResume of an activity the system + // recreated, so this can run before a mode has been slotted — see modeSettings. + writeMode { slot -> settingsRepository.setGeoTagging(slot, value) } + + chrome.onGeoTaggingChanged(value) + + field = value + } + + var selfIlluminate: Boolean + get() { + return modeSettings.selfIllumination && + session.lensFacing == CameraSelector.LENS_FACING_FRONT + } + set(value) { + writeMode { slot -> settingsRepository.setSelfIllumination(slot, value) } + + chrome.onSelfIlluminationChanged(value) + } + + fun attach( + environment: CameraSessionEnvironment, + effects: ViewfinderEffects, + chrome: ViewfinderChrome, + session: CameraSession, + ) { + attachment = Attachment( + environment = environment, + effects = effects, + chrome = chrome, + session = session, + ) + + mPlayer = environment.createTunePlayer() + + session.listener = this + } + + fun detach() { + attachment?.session?.listener = null + + attachment = null + mPlayer = null + } + + override fun onZoomStateChanged() { + chrome.updateZoomThumb(shouldShowPanel = true) + } + + override fun onCameraProviderUnavailable() { + effects.showMessage(R.string.camera_provider_init_failure) + } + + override fun onExtensionsUnavailable() { + effects.showMessage(R.string.extensions_manager_init_failure) + } + + override fun onProviderReady(forced: Boolean) { + startCamera(forced = forced) + } + + fun setFlashMode(value: Int) { + writeMode { slot -> settingsRepository.setFlashMode(slot, value) } + + applyFlashMode(value) + } + + fun shouldShowGyroscope(): Boolean { + return isInPhotoMode && settings.gyroscopeSuggestions + } + + fun reloadSettings() { + slotCurrentMode() + + if (isVideoMode) { + chrome.reloadVideoQualities() + } + + applyFlashMode(modeSettings.flashMode) + + // A stored "on" is written before a permission request resolves, and it outlives a later + // revocation, so it cannot be asserted on its own: doing so opened a permission dialog on + // startup that the user never asked for. Coercing it here settles the stale value through + // the setter, and leaves every dialog in the app originating from an explicit toggle. + requireLocation = modeSettings.geoTagging && + !environment.shouldAskForLocationPermission() + + selfIlluminate = modeSettings.selfIllumination + + chrome.showOnlyRelevantSettings() + } + + fun loadSettings() { + includeAudio = settings.includeAudio + } + + fun toggleFlashMode() { + when { + session.isFlashAvailable -> { + val next = when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON + ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO + else -> ImageCapture.FLASH_MODE_OFF + } + + setFlashMode(next) + } + + else -> effects.showMessage(R.string.flash_unavailable_in_selected_mode) + } + } + + fun toggleAspectRatio() { + aspectRatio = when (aspectRatio) { + AspectRatio.RATIO_16_9 -> AspectRatio.RATIO_4_3 + else -> AspectRatio.RATIO_16_9 + } + + startCamera(true) + } + + fun toggleCameraSelector() { + // Manually switch to the opposite lens facing + session.lensFacing = when (session.lensFacing) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK + } + + val isNewLensSupported = session.isLensFacingSupported( + lensFacing = session.lensFacing, + extensionMode = currentMode.extensionMode, + ) + + when { + isNewLensSupported -> startCamera(true) + + // Else revert back to the old facing (while displaying an error message + // to the user) + else -> { + session.lensFacing = when (session.lensFacing) { + CameraSelector.LENS_FACING_BACK -> { + effects.showMessage(R.string.rear_camera_unavailable) + CameraSelector.LENS_FACING_FRONT + } + + else -> { + effects.showMessage(R.string.front_camera_unavailable) + CameraSelector.LENS_FACING_BACK + } + } + } + } + } + + override fun onFeaturesSelected( + boundLensFacing: Int, + requested: List, + qualityFeature: GroupableFeature?, + selected: Set, + ) { + // The full request-vs-result picture (including which stabilization feature, if any, + // survived) is only ever logged, never shown: the lead wants EIS left silently in its + // known state -- 4K keeps priority and stabilization is given up without a notice. + Log.i(TAG, "Requested $requested but got $selected") + + val droppedQuality = resolveDroppedVideoQuality( + lensFacing = boundLensFacing, + requestedQualityFeature = qualityFeature, + selected = selected, + ) ?: return + + effects.showVideoQualityUnsupported(droppedQuality) + } + + fun initializeCamera(forced: Boolean = false) { + session.initialize(forced = forced, extensionMode = currentMode.extensionMode) + } + + // Start the camera with latest hard configuration + fun startCamera(forced: Boolean = false) { + if ((!forced && session.camera != null) || session.cameraProvider == null) return + + // Cancel any pending capture requests + effects.cancelPendingCapture() + + chrome.hideExposurePanel() + slotCurrentMode() + + // Before the builder below reads it: the mode just slotted may store a different flash mode + // than the one that was bound, and the ImageCapture is configured once, at build time. + applyFlashMode(modeSettings.flashMode) + + val rotation = environment.displayRotation + + if (!environment.isSessionActive) return + + // Test whether the current lens facing is supported by the current device + // If not then silently switch to the other lens facing + // (Snackbar/popup message can be shown before startCamera is called + // in specific cases of explicitly switching to another side or if + // the camera is expected) + val isCurrentLensSupported = session.isLensFacingSupported( + lensFacing = session.lensFacing, + extensionMode = currentMode.extensionMode, + ) + + if (!isCurrentLensSupported) { + session.lensFacing = when (session.lensFacing) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK + } + } + + session.selectLensFacing(session.lensFacing) + + // To use the last frame instead of showing a blank screen when + // the camera that is being currently used gets unbind + effects.updateLastFrame() + + val qrLensFacing = when { + isQRMode -> { + effects.startFocusTimer() + qrLensFacing() + } + + else -> null + } + + if (isVideoMode) { + chrome.setMicMutedIconVisible(!includeAudio) + } + + effects.forceUpdateOrientationSensor() + + val bindSettings = CameraBindSettings( + mode = currentMode, + isQrMode = isQRMode, + isVideoMode = isVideoMode, + requiresVideoModeOnly = entryPoint.requiresVideoModeOnly, + qrLensFacing = qrLensFacing, + rotation = rotation, + aspectRatio = aspectRatio, + flashMode = flashMode, + photoQuality = photoQuality, + videoQuality = videoQuality, + waitForFocusLock = waitForFocusLock, + enableZsl = settings.enableZsl, + enableEis = enableEIS, + selectHighestResolution = settings.selectHighestResolution, + mirrorVideoOnFrontCamera = settings.saveVideoAsPreviewed, + ) + + return when (session.bind(bindSettings)) { + BindOutcome.FAILED -> effects.showMessage(R.string.bind_failure) + + BindOutcome.EXTENSION_UNUSABLE -> { + effects.showMessage(R.string.extension_mode_unavailable) + + // The bind never completed: currentMode still names the mode that was just + // disabled and nothing is rendering into the preview. Refreshing the tabs + // alone would only *visually* select another tab, leaving a frozen preview + // behind a lying tab bar. Switch for real -- switchMode() rebinds, moves the + // highlight and refreshes the tabs. Recursion stops because the default mode + // uses no extension. + switchMode(DEFAULT_CAMERA_MODE) + } + + BindOutcome.BOUND -> announceBind() + } + } + fun onStorageLocationNotFound() { + runBlocking { revertToMediaStoreLocation() } + + effects.showStorageLocationNotFound() + } + + fun snapPreview() { + effects.flashPreview(selfIlluminate) + } + + fun switchMode(mode: CameraMode) { + if (currentMode == mode) { + return + } + + currentMode = mode + + effects.cancelFocusTimer() + + chrome.applyModeChrome( + mode = mode, + isVideoMode = isVideoMode, + scanAllCodes = scanAllCodes, + ) + + startCamera(true) + + // A mode can change with no touch involved, so the strip follows the camera and not the + // other way round - currentMode, because an extension that fails to bind falls back to + // another mode from inside startCamera(). Left until after that rebind, which blocks the + // main thread for long enough to swallow the animation whole. + if (entryPoint.showsCameraModeTabs) { + chrome.goToModeTab(currentMode) + } + } + + private fun applyFlashMode(value: Int) { + flashMode = value + session.imageCapture?.flashMode = value + chrome.onFlashModeChanged() + } + + private fun slotCurrentMode() { + val current = ModeSlot( + mode = currentMode, + isFrontFacing = session.lensFacing == CameraSelector.LENS_FACING_FRONT, + ) + + slot = current + + modeSettings = runBlocking { settingsRepository.modeSettings(current) } + } + + private fun writeMode(write: suspend (ModeSlot) -> ModeSettings) { + val current = slot ?: return + + modeSettings = runBlocking { write(current) } + } + + private fun qrLensFacing(): Int { + val isRearLensSupported = session.isLensFacingSupported( + lensFacing = CameraSelector.LENS_FACING_BACK, + extensionMode = currentMode.extensionMode, + ) + + if (isRearLensSupported) { + return CameraSelector.LENS_FACING_BACK + } + + effects.showMessage(R.string.qr_rear_camera_unavailable) + + return CameraSelector.LENS_FACING_FRONT + } + + private fun announceBind() { + loadTabs() + + session.reattachZoomState() + + chrome.updateZoomThumb(shouldShowPanel = false) + + session.camera?.cameraInfo?.exposureState?.let { chrome.applyExposureState(it) } + + chrome.resetTorchToggle() + + session.camera?.cameraInfo?.let { chrome.onPreviewBound(aspectRatio, it) } + + chrome.updateGyroscopeIndicator(isInPhotoMode) + } + + private fun loadTabs() { + if (!entryPoint.showsCameraModeTabs) { + return + } + + session.probeUnknownExtensions( + onRestart = ::loadTabs, + onSettled = ::buildTabs, + ) + } + + private fun buildTabs() { + chrome.setCameraModeTabs( + modes = resolveAvailableModes( + allowsQrScanning = entryPoint.allowsQrScanning, + extensionsAvailable = session.extensionsAvailable, + ), + currentMode = currentMode, + ) + } + + private fun setting( + read: (CameraSettings) -> T, + write: (CameraSettings, T) -> CameraSettings, + onChanged: (T) -> Unit = {}, + ): ReadWriteProperty { + return object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + return read(settings) + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + runBlocking { settingsRepository.update { write(it, value) } } + + onChanged(value) + } + } + } + + private class Attachment( + val environment: CameraSessionEnvironment, + val effects: ViewfinderEffects, + val chrome: ViewfinderChrome, + val session: CameraSession, + ) + + companion object { + private const val TAG = "ViewfinderController" + + val DEFAULT_CAMERA_MODE = CameraMode.CAMERA + } +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt new file mode 100644 index 000000000..8deca787b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt @@ -0,0 +1,320 @@ +package app.grapheneos.camera.ui.viewfinder.screen + +import android.content.Context +import android.os.Build +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import androidx.annotation.StringRes +import androidx.camera.core.CameraInfo +import androidx.camera.core.ExposureState +import androidx.camera.core.Preview +import androidx.camera.video.Quality +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import app.grapheneos.camera.App +import app.grapheneos.camera.R +import app.grapheneos.camera.TunePlayer +import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.camera.session.CameraSessionEnvironment +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.ktx.applyPreviewRatio +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog +import app.grapheneos.camera.ui.videoQualityTitle +import java.util.concurrent.Executor + +internal class ViewfinderEffectHandler( + private val activity: MainActivity, +) : CameraSessionEnvironment, + ViewfinderEffects, + ViewfinderChrome { + + override val sessionContext: Context + get() { + return activity + } + + override val sessionLifecycleOwner: LifecycleOwner + get() { + return activity + } + + override val sessionMainExecutor: Executor + get() { + return ContextCompat.getMainExecutor(activity) + } + + override val isSessionActive: Boolean + get() { + return !activity.isDestroyed && !activity.isFinishing + } + + override val previewSurfaceProvider: Preview.SurfaceProvider + get() { + return activity.previewView.surfaceProvider + } + + override val displayRotation: Int + get() { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + activity.display?.rotation ?: deprecatedDisplayRotation() + } + + // We don't really have any option here, but this initialization ensures that the + // app doesn't break later when the below deprecated option gets removed post + // Android R + else -> deprecatedDisplayRotation() + } + } + + override fun showMessage(@StringRes message: Int) { + activity.showMessage(message) + } + + override fun showVideoQualityUnsupported(quality: Quality) { + activity.showMessage( + activity.getString( + R.string.quality_unsupported, + videoQualityTitle(activity, quality), + ) + ) + } + + override fun updateLastFrame() { + activity.updateLastFrame() + } + + override fun forceUpdateOrientationSensor() { + activity.forceUpdateOrientationSensor() + } + + override fun startFocusTimer() { + activity.startFocusTimer() + } + + override fun cancelFocusTimer() { + activity.cancelFocusTimer() + } + + override fun onRequireLocationChanged(required: Boolean) { + activity.onRequireLocationChanged(required) + } + + override fun shouldAskForLocationPermission(): Boolean { + return (activity.applicationContext as App).shouldAskForLocationPermission() + } + + override fun createTunePlayer(): TunePlayer { + return TunePlayer(activity) + } + + override fun createQrAnalyzer(): QRAnalyzer { + return QRAnalyzer(activity) + } + + override fun showStorageLocationNotFound() { + showStorageLocationNotFoundDialog(activity) + } + + override fun cancelPendingCapture() { + activity.imageCapturer.cancelPendingCaptureRequest() + } + + override fun hideExposurePanel() { + activity.exposureBar.hidePanel() + } + + override fun applyExposureState(exposureState: ExposureState) { + activity.exposureBar.setExposureConfig(exposureState) + } + + override fun updateZoomThumb(shouldShowPanel: Boolean) { + activity.zoomBar.updateThumb(shouldShowPanel) + } + + override fun setMicMutedIconVisible(visible: Boolean) { + activity.micOffIcon.visibility = when { + visible -> View.VISIBLE + else -> View.GONE + } + } + + override fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) { + // Focus camera on touch/tap + activity.previewView.setOnTouchListener(activity.gestureHandler) + activity.previewView.applyPreviewRatio(aspectRatio, cameraInfo) + } + + override fun updateGyroscopeIndicator(inPhotoMode: Boolean) { + when { + inPhotoMode -> activity.sensorNotifier?.forceUpdateGyro() + else -> activity.gCircleFrame.visibility = View.GONE + } + } + + override fun setCameraModeTabs(modes: Set, currentMode: CameraMode) { + activity.tabLayout.setModes( + modes = modes, + currentMode = currentMode, + onTabTouched = activity::finalizeMode, + ) + } + + override fun goToModeTab(mode: CameraMode) { + activity.tabLayout.getTabForMode(mode)?.let { tab -> + activity.tabLayout.goToTab(tab) + } + } + + override fun onFlashModeChanged() { + activity.settingsDialog.updateFlashMode() + } + + override fun onIncludeAudioChanged(enabled: Boolean) { + activity.settingsDialog.includeAudioToggle.isChecked = enabled + } + + override fun onGeoTaggingChanged(enabled: Boolean) { + activity.settingsDialog.locToggle.isChecked = enabled + } + + override fun onSelfIlluminationChanged(enabled: Boolean) { + activity.settingsDialog.selfIlluminationToggle.isChecked = enabled + activity.settingsDialog.selfIllumination() + } + + override fun reloadVideoQualities() { + activity.settingsDialog.reloadQualities() + } + + override fun showOnlyRelevantSettings() { + activity.settingsDialog.showOnlyRelevantSettings() + } + + override fun resetTorchToggle() { + activity.settingsDialog.torchToggle.isChecked = false + } + + override fun applyModeChrome( + mode: CameraMode, + isVideoMode: Boolean, + scanAllCodes: Boolean, + ) { + when (mode) { + CameraMode.QR_SCAN -> { + activity.qrOverlay.visibility = View.VISIBLE + activity.thirdOption.visibility = View.INVISIBLE + + applyScanAllCodesChrome(scanAllCodes) + + activity.cancelButtonView.visibility = View.INVISIBLE + + activity.captureButton.setBackgroundResource(android.R.color.transparent) + // Entering QR mode always leaves the torch off + activity.setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) + + activity.micOffIcon.visibility = View.GONE + } + + else -> { + activity.qrOverlay.visibility = View.INVISIBLE + activity.thirdOption.visibility = View.VISIBLE + activity.setFlipCameraIcon(R.drawable.flip_camera, R.string.flip_camera) + activity.cancelButtonView.visibility = View.VISIBLE + + activity.qrScanToggles.visibility = View.GONE + + activity.captureButton.setBackgroundResource(R.drawable.cbutton_bg) + + when { + isVideoMode -> { + activity.setCaptureButtonIcon( + icon = R.drawable.recording, + description = R.string.start_recording, + ) + } + + else -> { + activity.setCaptureButtonIcon( + icon = R.drawable.camera_shutter, + description = R.string.capture, + ) + + activity.micOffIcon.visibility = View.GONE + } + } + } + } + + activity.updateSelfTimerBadge() + } + + override fun applyScanAllCodesChrome(scanAllCodes: Boolean) { + when { + scanAllCodes -> { + activity.setFlipCameraIcon(R.drawable.cancel, R.string.stop_scanning_all_formats) + activity.qrScanToggles.visibility = View.GONE + } + + else -> { + activity.setFlipCameraIcon(R.drawable.auto, R.string.scan_all_formats) + activity.qrScanToggles.visibility = View.VISIBLE + } + } + } + + override fun flashPreview(selfIlluminate: Boolean) { + val animation: Animation = when { + selfIlluminate -> AlphaAnimation(0f, 0.8f) + else -> AlphaAnimation(1f, 0f) + } + + animation.interpolator = LinearInterpolator() + + when { + selfIlluminate -> { + animation.duration = PREVIEW_SL_OVERLAY_DUR + animation.fillAfter = true + activity.mainOverlay.setImageResource(android.R.color.white) + } + + else -> { + animation.duration = PREVIEW_SNAP_DURATION + animation.repeatMode = Animation.REVERSE + activity.mainOverlay.setImageResource(android.R.color.black) + } + } + + animation.setAnimationListener( + object : Animation.AnimationListener { + override fun onAnimationStart(p0: Animation?) { + activity.mainOverlay.visibility = View.VISIBLE + } + + override fun onAnimationEnd(p0: Animation?) { + if (!selfIlluminate) { + activity.mainOverlay.visibility = View.INVISIBLE + activity.mainOverlay.setImageResource(android.R.color.transparent) + } + } + + override fun onAnimationRepeat(p0: Animation?) {} + } + ) + + activity.mainOverlay.startAnimation(animation) + } + + @Suppress("DEPRECATION") + private fun deprecatedDisplayRotation(): Int { + return activity.windowManager.defaultDisplay.rotation + } + + private companion object { + private const val PREVIEW_SNAP_DURATION = 200L + private const val PREVIEW_SL_OVERLAY_DUR = 200L + } +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffects.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffects.kt new file mode 100644 index 000000000..2a6f347d2 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffects.kt @@ -0,0 +1,25 @@ +package app.grapheneos.camera.ui.viewfinder.screen + +import androidx.annotation.StringRes +import androidx.camera.video.Quality + +interface ViewfinderEffects { + + fun showMessage(@StringRes message: Int) + + fun showVideoQualityUnsupported(quality: Quality) + + fun showStorageLocationNotFound() + + fun flashPreview(selfIlluminate: Boolean) + + fun updateLastFrame() + + fun cancelPendingCapture() + + fun forceUpdateOrientationSensor() + + fun startFocusTimer() + + fun cancelFocusTimer() +} diff --git a/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt b/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt index 57a19ea27..dab360d36 100644 --- a/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt +++ b/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt @@ -1,11 +1,11 @@ package app.grapheneos.camera.util import androidx.camera.core.ZoomState -import app.grapheneos.camera.CamConfig +import app.grapheneos.camera.data.camera.session.CameraSession -class CameraControl(private val camConfig: CamConfig) { +class CameraControl(private val session: CameraSession) { - private fun zoomState(): ZoomState? = camConfig.zoomState + private fun zoomState(): ZoomState? = session.zoomState fun zoomIn() = zoomByRatio(1f) @@ -23,7 +23,7 @@ class CameraControl(private val camConfig: CamConfig) { else if (currentZoomRatio < 1 && newZoomRatio > 1) 1f else newZoomRatio - camConfig.camera?.cameraControl?.setZoomRatio(zoomTo) + session.camera?.cameraControl?.setZoomRatio(zoomTo) } } diff --git a/app/src/main/java/app/grapheneos/camera/util/Utils.kt b/app/src/main/java/app/grapheneos/camera/util/Utils.kt index 416014fce..fad67e008 100644 --- a/app/src/main/java/app/grapheneos/camera/util/Utils.kt +++ b/app/src/main/java/app/grapheneos/camera/util/Utils.kt @@ -7,11 +7,11 @@ import android.net.Uri import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.MediaStore -import app.grapheneos.camera.CamConfig import app.grapheneos.camera.R import app.grapheneos.camera.capturer.DEFAULT_MEDIA_STORE_CAPTURE_PATH import app.grapheneos.camera.capturer.SAF_URI_HOST_EXTERNAL_STORAGE import app.grapheneos.camera.data.media.repository.CapturedItemRepository +import app.grapheneos.camera.ui.viewfinder.screen.ViewfinderController import java.io.ByteArrayOutputStream import java.io.IOException import java.io.PrintStream diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af61a0a50..a7ed7e568 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,8 +191,6 @@ Already using the default storage location Photo quality can only be between %1$d and %2$d Images taken in this mode don\'t contain extra EXIF data - File exists at %s - File does not exist at %s Couldn\'t exclude %s format. Please ensure at least one format is selected in manual mode. Capturing Image… diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt new file mode 100644 index 000000000..a9223fd4e --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt @@ -0,0 +1,109 @@ +package app.grapheneos.camera.data.camera + +import androidx.camera.core.CameraSelector +import androidx.camera.extensions.ExtensionMode +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepositoryImpl +import app.grapheneos.camera.data.core.model.CameraMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExtensionAvailabilityRepositoryImplTest { + + private val repository = ExtensionAvailabilityRepositoryImpl() + + @Test + fun verdict_anUnprobedKey_isUnknown() { + assertNull(repository.verdict(NIGHT_FRONT)) + } + + @Test + fun record_aVerdict_readsBack() { + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = false) + + assertTrue(repository.verdict(NIGHT_FRONT) == true) + assertFalse(repository.verdict(NIGHT_BACK) == true) + } + + @Test + fun unprobed_aColdCache_listsEveryExtensionModeOnBothLenses() { + val extensionModes = CameraMode.entries + .map { it.extensionMode } + .filter { it != ExtensionMode.NONE } + .toSet() + + val unprobed = repository.unprobed() + + assertEquals(extensionModes.size * 2, unprobed.size) + assertEquals(extensionModes, unprobed.map { it.extensionMode }.toSet()) + assertEquals( + setOf(CameraSelector.LENS_FACING_FRONT, CameraSelector.LENS_FACING_BACK), + unprobed.map { it.lensFacing }.toSet(), + ) + } + + @Test + fun unprobed_aModeWithoutAnExtension_isNeverListed() { + assertTrue(repository.unprobed().none { it.extensionMode == ExtensionMode.NONE }) + } + + @Test + fun unprobed_anAnsweredKey_dropsOutOfTheList() { + repository.record(NIGHT_FRONT, usable = false) + + assertFalse(NIGHT_FRONT in repository.unprobed()) + assertTrue(NIGHT_BACK in repository.unprobed()) + } + + @Test + fun recordProbeRound_aVerdictForAnUnprobedKey_isRemembered() { + repository.recordProbeRound(mapOf(NIGHT_FRONT to true)) + + assertTrue(repository.verdict(NIGHT_FRONT) == true) + } + + @Test + fun recordProbeRound_aTransientFailure_isNotRemembered() { + repository.recordProbeRound(mapOf(NIGHT_FRONT to null)) + + assertNull(repository.verdict(NIGHT_FRONT)) + } + + @Test + fun recordProbeRound_aKeyBlacklistedWhileTheRoundWasInFlight_staysBlacklisted() { + repository.record(NIGHT_FRONT, usable = false) + repository.recordProbeRound(mapOf(NIGHT_FRONT to true)) + + assertFalse(repository.verdict(NIGHT_FRONT) == true) + } + + @Test + fun clear_aWarmCache_forgetsEveryVerdict() { + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = false) + + repository.clear() + + assertNull(repository.verdict(NIGHT_FRONT)) + assertNull(repository.verdict(NIGHT_BACK)) + } + + private companion object { + val NIGHT_FRONT = ExtensionKey( + lensFacing = CameraSelector.LENS_FACING_FRONT, + extensionMode = ExtensionMode.NIGHT, + ) + + val NIGHT_BACK = ExtensionKey( + lensFacing = CameraSelector.LENS_FACING_BACK, + extensionMode = ExtensionMode.NIGHT, + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt new file mode 100644 index 000000000..940f09ecc --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt @@ -0,0 +1,54 @@ +package app.grapheneos.camera.data.camera.mapper + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import androidx.camera.video.Quality +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VideoQualityFeatureMapperImplTest { + + private val mapper = VideoQualityFeatureMapperImpl() + + @Test + fun map_aQualityNamingAResolution_hasAGroupableEquivalent() { + assertEquals(GroupableFeatures.UHD_RECORDING, mapper.map(Quality.UHD)) + assertEquals(GroupableFeatures.FHD_RECORDING, mapper.map(Quality.FHD)) + assertEquals(GroupableFeatures.HD_RECORDING, mapper.map(Quality.HD)) + assertEquals(GroupableFeatures.SD_RECORDING, mapper.map(Quality.SD)) + } + + @Test + fun map_aQualityNamingNoResolution_hasNoGroupableEquivalent() { + assertNull(mapper.map(Quality.HIGHEST)) + assertNull(mapper.map(Quality.LOWEST)) + } + + @Test + fun map_aFeatureNamingNoQuality_namesNoQuality() { + assertNull(mapper.map(GroupableFeature.PREVIEW_STABILIZATION)) + assertNull(mapper.map(GroupableFeatures.VIDEO_STABILIZATION)) + } + + @Test + fun map_everyGroupableQuality_readsBackAsItself() { + GROUPABLE_QUALITIES.forEach { quality -> + val feature = mapper.map(quality) + + assertEquals(quality, feature?.let { mapper.map(it) }) + } + } + + private companion object { + val GROUPABLE_QUALITIES = listOf( + Quality.UHD, + Quality.FHD, + Quality.HD, + Quality.SD, + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt new file mode 100644 index 000000000..0445f7aec --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt @@ -0,0 +1,174 @@ +package app.grapheneos.camera.data.camera.session + +import androidx.camera.core.AspectRatio +import androidx.camera.core.ImageCapture +import androidx.camera.core.SessionConfig +import androidx.camera.core.UseCase +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import androidx.camera.video.Quality +import app.grapheneos.camera.data.camera.model.CameraBindRequest +import app.grapheneos.camera.data.camera.model.CameraSessionPlan +import app.grapheneos.camera.data.camera.model.FeatureGroupRequest +import app.grapheneos.camera.data.camera.model.ImageCaptureMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CameraSessionPlanFactoryImplTest { + + @Test + fun invoke_photoMode_buildsAPhotoAndPreviewSession() { + val plan = buildPlan(request()) + + assertNotNull(plan.imageCapture) + assertNull(plan.videoCapture) + assertTrue(plan.preferredFeatures.isEmpty()) + } + + @Test + fun invoke_videoModeWithoutPhotoCapture_buildsNoImageCapture() { + val plan = buildPlan( + request(includesVideoCapture = true, includesImageCapture = false), + ) + + assertNotNull(plan.videoCapture) + assertNull(plan.imageCapture) + } + + @Test + fun invoke_focusLockWinsOverZeroShutterLag() { + val plan = buildPlan(request(waitForFocusLock = true, enableZsl = true)) + + assertEquals(ImageCaptureMode.MAXIMIZE_QUALITY, plan.captureMode) + assertEquals(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY, plan.imageCapture?.captureMode) + } + + @Test + fun invoke_zeroShutterLagAlone_isRequested() { + val plan = buildPlan(request(enableZsl = true)) + + assertEquals(ImageCaptureMode.ZERO_SHUTTER_LAG, plan.captureMode) + assertEquals(ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG, plan.imageCapture?.captureMode) + } + + @Test + fun invoke_neitherFocusLockNorZeroShutterLag_minimizesLatency() { + val plan = buildPlan(request()) + + assertEquals(ImageCaptureMode.MINIMIZE_LATENCY, plan.captureMode) + assertEquals(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, plan.imageCapture?.captureMode) + } + + @Test + fun invoke_theRequestedFlashAndRotation_reachTheImageCapture() { + val plan = buildPlan( + request(flashMode = ImageCapture.FLASH_MODE_ON, imageCaptureTargetRotation = 3), + ) + + assertEquals(ImageCapture.FLASH_MODE_ON, plan.imageCapture?.flashMode) + assertEquals(3, plan.imageCapture?.targetRotation) + } + + @Test + fun invoke_aFeatureGroupSession_leadsWithTheVideoQuality() { + val plan = buildPlan(featureGroupRequest()) + + assertEquals( + listOf( + GroupableFeatures.UHD_RECORDING, + GroupableFeature.PREVIEW_STABILIZATION, + GroupableFeatures.VIDEO_STABILIZATION, + ), + plan.preferredFeatures, + ) + } + + @Test + fun invoke_aFeatureGroupSessionWithoutAMappableQuality_asksOnlyForStabilization() { + val plan = buildPlan(featureGroupRequest(videoQualityFeature = null)) + + assertEquals( + listOf( + GroupableFeature.PREVIEW_STABILIZATION, + GroupableFeatures.VIDEO_STABILIZATION, + ), + plan.preferredFeatures, + ) + } + + // CameraX 1.6.0's SessionConfig rejects at construction time any use case that configured a + // groupable feature through a non-groupable API while a feature group is in use -- + // Recorder.Builder.setQualitySelector() and Preview.Builder.setPreviewStabilizationEnabled() + // are both such APIs. Constructing the SessionConfig the bind would construct is what proves + // the plan kept away from them. + @Test + fun invoke_aFeatureGroupSession_constructsASessionConfigWithoutThrowing() { + val plan = buildPlan(featureGroupRequest()) + + val sessionConfig = SessionConfig( + useCases = plan.useCases(), + preferredFeatureGroup = plan.preferredFeatures, + ) + + assertEquals(3, sessionConfig.useCases.size) + } + + @Test + fun invoke_aPlainSession_constructsASessionConfigWithoutThrowing() { + val plan = buildPlan(request(includesVideoCapture = true, includesImageCapture = true)) + + val sessionConfig = SessionConfig(useCases = plan.useCases()) + + assertEquals(3, sessionConfig.useCases.size) + } + + private fun buildPlan(request: CameraBindRequest): CameraSessionPlan { + return CameraSessionPlanFactoryImpl().create(request) + } + + private fun CameraSessionPlan.useCases(): List { + return listOfNotNull(videoCapture, imageCapture, preview) + } + + private fun featureGroupRequest( + videoQualityFeature: GroupableFeature? = GroupableFeatures.UHD_RECORDING, + ): CameraBindRequest { + return request( + includesVideoCapture = true, + includesImageCapture = true, + featureGroup = FeatureGroupRequest.Requested(videoQualityFeature), + ) + } + + private fun request( + includesVideoCapture: Boolean = false, + includesImageCapture: Boolean = true, + flashMode: Int = ImageCapture.FLASH_MODE_OFF, + imageCaptureTargetRotation: Int = 0, + waitForFocusLock: Boolean = false, + enableZsl: Boolean = false, + featureGroup: FeatureGroupRequest = FeatureGroupRequest.Unused, + ): CameraBindRequest { + return CameraBindRequest( + includesVideoCapture = includesVideoCapture, + includesImageCapture = includesImageCapture, + aspectRatio = AspectRatio.RATIO_16_9, + imageCaptureTargetRotation = imageCaptureTargetRotation, + previewTargetRotation = 0, + flashMode = flashMode, + photoQuality = 90, + waitForFocusLock = waitForFocusLock, + enableZsl = enableZsl, + selectHighestResolution = false, + videoQuality = Quality.UHD, + mirrorVideoOnFrontCamera = false, + featureGroup = featureGroup, + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt new file mode 100644 index 000000000..3da097ff4 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt @@ -0,0 +1,111 @@ +package app.grapheneos.camera.data.camera.session + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import app.grapheneos.camera.data.camera.model.InVideoSnapshotSupport +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InVideoSnapshotSupportResolverImplTest { + + private val resolver = InVideoSnapshotSupportResolverImpl() + + @Test + fun invoke_noQualityAskedForAndTheStreamsBind_keepsSnapshots() { + val probe = FakeSessionProbe(supported = setOf(PLAIN_STREAMS_WITH_SNAPSHOTS)) + + val support = resolver.resolve(videoQualityFeature = null, probe = probe) + + assertEquals(InVideoSnapshotSupport.Supported, support) + } + + @Test + fun invoke_noQualityAskedForAndTheStreamsDoNotBind_dropsSnapshots() { + val probe = FakeSessionProbe(supported = emptySet()) + + val support = resolver.resolve(videoQualityFeature = null, probe = probe) + + assertEquals(InVideoSnapshotSupport.Unsupported.STREAM_COMBINATION, support) + } + + @Test + fun invoke_theQualityBindsWithSnapshots_keepsSnapshotsWithoutAskingAgain() { + val probe = FakeSessionProbe(supported = setOf(QUALITY_WITH_SNAPSHOTS)) + + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) + + assertEquals(InVideoSnapshotSupport.Supported, support) + assertEquals(listOf(QUALITY_WITH_SNAPSHOTS), probe.asked) + } + + @Test + fun invoke_theQualityBindsOnlyWithoutSnapshots_givesUpTheSnapshots() { + val probe = FakeSessionProbe(supported = setOf(QUALITY_WITHOUT_SNAPSHOTS)) + + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) + + assertEquals(InVideoSnapshotSupport.Unsupported.SELECTED_VIDEO_QUALITY, support) + } + + @Test + fun invoke_theQualityBindsNeitherWay_keepsSnapshotsAndLeavesTheQualityToTheResolver() { + val probe = FakeSessionProbe(supported = setOf(PLAIN_STREAMS_WITH_SNAPSHOTS)) + + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) + + assertEquals(InVideoSnapshotSupport.Supported, support) + } + + @Test + fun invoke_nothingBinds_dropsSnapshots() { + val probe = FakeSessionProbe(supported = emptySet()) + + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) + + assertEquals(InVideoSnapshotSupport.Unsupported.STREAM_COMBINATION, support) + } + + private class FakeSessionProbe( + private val supported: Set, + ) : SessionProbe { + + val asked = mutableListOf() + + override fun isSupported( + withSnapshots: Boolean, + features: Set, + ): Boolean { + val question = Question(withSnapshots = withSnapshots, features = features) + asked.add(question) + + return question in supported + } + } + + private data class Question( + val withSnapshots: Boolean, + val features: Set, + ) + + private companion object { + val QUALITY: GroupableFeature = GroupableFeatures.UHD_RECORDING + + val PLAIN_STREAMS_WITH_SNAPSHOTS = Question( + withSnapshots = true, + features = emptySet(), + ) + + val QUALITY_WITH_SNAPSHOTS = Question( + withSnapshots = true, + features = setOf(QUALITY), + ) + + val QUALITY_WITHOUT_SNAPSHOTS = Question( + withSnapshots = false, + features = setOf(QUALITY), + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt b/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt index ec9919a18..5c4ca3e21 100644 --- a/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt +++ b/app/src/test/java/app/grapheneos/camera/data/core/LegacyPreferences.kt @@ -8,7 +8,6 @@ import app.grapheneos.camera.data.core.model.CameraMode import java.io.File internal const val LEGACY_COMMON_PREFS_NAME = "commons" -internal const val LEGACY_MEDIA_PREFS_NAME = "media" internal val LEGACY_CAPTURE_KEY_NAMES = setOf( "last_captured_item_type", @@ -70,10 +69,6 @@ internal fun legacyModeFile(context: Context, mode: CameraMode): SharedPreferenc return context.getSharedPreferences(mode.name, Context.MODE_PRIVATE) } -internal fun legacyMediaFile(context: Context): SharedPreferences { - return context.getSharedPreferences(LEGACY_MEDIA_PREFS_NAME, Context.MODE_PRIVATE) -} - internal fun writeLegacyPreferences(context: Context) { legacyCommonsFile(context).edit(commit = true) { LEGACY_COMMON_ENTRIES.forEach { (key, value) -> put(key, value) } @@ -94,10 +89,6 @@ internal fun legacyModeFileExists(context: Context, mode: CameraMode): Boolean { return legacyFile(context, mode.name).exists() } -internal fun legacyMediaFileExists(context: Context): Boolean { - return legacyFile(context, LEGACY_MEDIA_PREFS_NAME).exists() -} - private fun legacyFile(context: Context, name: String): File { return File(context.dataDir, "shared_prefs/$name.xml") } @@ -106,9 +97,6 @@ internal fun clearLegacyPreferences(context: Context) { legacyCommonsFile(context).edit(commit = true) { clear() } context.deleteSharedPreferences(LEGACY_COMMON_PREFS_NAME) - legacyMediaFile(context).edit(commit = true) { clear() } - context.deleteSharedPreferences(LEGACY_MEDIA_PREFS_NAME) - CameraMode.entries.forEach { mode -> legacyModeFile(context, mode).edit(commit = true) { clear() } context.deleteSharedPreferences(mode.name) diff --git a/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt index b9bd5985f..5ce3d8c52 100644 --- a/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt @@ -26,11 +26,13 @@ import app.grapheneos.camera.data.media.store.mediaPrefsSerializer import java.io.File import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -41,6 +43,7 @@ import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class CapturedItemRepositoryTest { @@ -72,6 +75,7 @@ class CapturedItemRepositoryTest { storagePrefs = session, mediaPrefs = media, context = context, + ioDispatcher = UnconfinedTestDispatcher(), ) } @@ -108,7 +112,7 @@ class CapturedItemRepositoryTest { fun lastCapturedItem_afterRestart_returnsTheStoredItem() { runBlocking { repository().saveLastCapturedItem(item(DATE_STRING)) } - val reloaded = runBlocking { repository().lastCapturedItem() } + val reloaded = runBlocking { repository().lastCapturedItem.first() } assertEquals(item(DATE_STRING), reloaded) assertEquals(ITEM_TYPE_IMAGE, reloaded?.type) @@ -116,7 +120,7 @@ class CapturedItemRepositoryTest { @Test fun lastCapturedItem_freshInstall_returnsNull() { - assertNull(runBlocking { repository().lastCapturedItem() }) + assertNull(runBlocking { repository().lastCapturedItem.first() }) } @Test @@ -137,7 +141,7 @@ class CapturedItemRepositoryTest { val onDisk = runBlocking { mediaPrefsSerializer.readFrom(file.inputStream()) } - assertEquals(item(DATE_STRING), runBlocking { session.lastCapturedItem() }) + assertEquals(item(DATE_STRING), runBlocking { session.lastCapturedItem.first() }) assertEquals( StoredCapturedItem( type = ITEM_TYPE_IMAGE, @@ -207,7 +211,7 @@ class CapturedItemRepositoryTest { fun migrateStoredCaptures_legacyUris_becomeTrackedTreesOnce() { writeLegacyMediaUris(listOf("treeA", "treeB")) - runBlocking { repository().migrateStoredCaptures { } } + runBlocking { repository().migrateStoredCaptures() } assertNull(stored().legacyMediaUris) assertEquals( @@ -218,7 +222,7 @@ class CapturedItemRepositoryTest { runBlocking { repository().setStorageLocation(treeUri("treeC").toString()) repository().setStorageLocation(treeUri("treeD").toString()) - repository().migrateStoredCaptures { } + repository().migrateStoredCaptures() } assertEquals( @@ -233,7 +237,7 @@ class CapturedItemRepositoryTest { runBlocking { storagePrefs.updateData { it.copy(storageLocation = location) } } writeLegacyMediaUris(listOf("treeA")) - runBlocking { repository().migrateStoredCaptures { } } + runBlocking { repository().migrateStoredCaptures() } assertEquals(emptyList(), previousSafTrees()) } @@ -244,7 +248,7 @@ class CapturedItemRepositoryTest { val trees = (0..cap).map { "tree$it" } writeLegacyMediaUris(trees) - runBlocking { repository().migrateStoredCaptures { } } + runBlocking { repository().migrateStoredCaptures() } assertEquals(trees.map { treeUri(it) }, previousSafTrees()) @@ -264,8 +268,7 @@ class CapturedItemRepositoryTest { Robolectric.buildContentProvider(FakeDocumentsProvider::class.java).create(AUTHORITY) writeLegacyMediaUris(listOf("treeA")) - var reported: CapturedItem? = null - runBlocking { repository().migrateStoredCaptures { reported = it } } + val reported = runBlocking { repository().migrateStoredCaptures() } assertEquals( CapturedItem( diff --git a/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt index ae30dcc99..40c822848 100644 --- a/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/media/MediaPrefsMigrationTest.kt @@ -10,8 +10,6 @@ import app.grapheneos.camera.data.core.LEGACY_ITEM_DATE_STRING import app.grapheneos.camera.data.core.LEGACY_ITEM_URI import app.grapheneos.camera.data.core.clearLegacyPreferences import app.grapheneos.camera.data.core.legacyCommonsFile -import app.grapheneos.camera.data.core.legacyMediaFile -import app.grapheneos.camera.data.core.legacyMediaFileExists import app.grapheneos.camera.data.core.writeLegacyPreferences import app.grapheneos.camera.data.media.store.MediaPrefs import app.grapheneos.camera.data.media.store.MediaPrefsMigration @@ -63,26 +61,7 @@ class MediaPrefsMigrationTest { } @Test - fun migrate_lastCapturedItemFromTheMediaFile_isCarriedOverBeforeTheOlderFile() { - writeCapturedItem( - preferences = legacyCommonsFile(context), - dateString = COMMONS_DATE_STRING, - uri = COMMONS_URI, - ) - writeCapturedItem( - preferences = legacyMediaFile(context), - dateString = MEDIA_DATE_STRING, - uri = MEDIA_URI, - ) - - val migrated = runBlocking { migration.migrate(MediaPrefs()) } - - assertEquals(MEDIA_DATE_STRING, migrated.lastCapturedItem?.dateString) - assertEquals(MEDIA_URI, migrated.lastCapturedItem?.uri) - } - - @Test - fun migrate_populatedDataStore_isKeptBeforeEitherLegacyFile() { + fun migrate_populatedDataStore_isKeptBeforeTheLegacyFile() { val current = StoredCapturedItem( type = ITEM_TYPE_IMAGE, dateString = CURRENT_DATE_STRING, @@ -93,11 +72,6 @@ class MediaPrefsMigrationTest { dateString = COMMONS_DATE_STRING, uri = COMMONS_URI, ) - writeCapturedItem( - preferences = legacyMediaFile(context), - dateString = MEDIA_DATE_STRING, - uri = MEDIA_URI, - ) assertEquals( current, @@ -142,11 +116,6 @@ class MediaPrefsMigrationTest { @Test fun cleanUp_removesOnlyItsOwnKeys() { writeLegacyPreferences(context) - writeCapturedItem( - preferences = legacyMediaFile(context), - dateString = MEDIA_DATE_STRING, - uri = MEDIA_URI, - ) runBlocking { migration.migrate(MediaPrefs()) @@ -157,7 +126,6 @@ class MediaPrefsMigrationTest { assertTrue(remaining.none { it in LEGACY_CAPTURE_KEY_NAMES }) assertTrue(remaining.contains("photo_quality")) - assertFalse(legacyMediaFileExists(context)) } private fun writeCapturedItem( @@ -176,8 +144,6 @@ class MediaPrefsMigrationTest { const val SOME_PHOTO_QUALITY = 71 const val COMMONS_DATE_STRING = "20260724_100000_000" const val COMMONS_URI = "content://media/external/images/media/2" - const val MEDIA_DATE_STRING = "20260724_110000_000" - const val MEDIA_URI = "content://media/external/images/media/3" const val CURRENT_DATE_STRING = "20260724_120000_000" const val CURRENT_URI = "content://media/external/images/media/4" } diff --git a/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt index 0fa407d2b..818f10ea4 100644 --- a/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/settings/SettingsRepositoryTest.kt @@ -14,6 +14,7 @@ import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapperImpl import app.grapheneos.camera.data.settings.model.CameraSettings import app.grapheneos.camera.data.settings.model.GridType import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.ModeSlot import app.grapheneos.camera.data.settings.model.SettingsDefaults import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl @@ -152,8 +153,8 @@ class SettingsRepositoryTest { runBlocking { repository.update { it.copy(photoQuality = OTHER_PHOTO_QUALITY) } - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(true) + repository.modeSettings(SLOT) + repository.setGeoTagging(SLOT, true) } assertEquals(OTHER_PHOTO_QUALITY, settingsOf(repository).photoQuality) @@ -181,15 +182,21 @@ class SettingsRepositoryTest { } @Test - fun settings_afterAnotherRepositoryWroteTheSameStore_picksUpTheChange() { - val viewfinder = repository() - val settingsScreen = repository() + fun settings_afterAWrite_isSettledWithoutWaitingForADispatch() { + val repository = repository() - assertEquals(SettingsDefaults.PHOTO_QUALITY, settingsOf(viewfinder).photoQuality) + assertEquals(SettingsDefaults.PHOTO_QUALITY, repository.settings.value.photoQuality) - runBlocking { settingsScreen.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + runBlocking { repository.update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } - assertEquals(SOME_PHOTO_QUALITY, settingsOf(viewfinder).photoQuality) + assertEquals(SOME_PHOTO_QUALITY, repository.settings.value.photoQuality) + } + + @Test + fun settings_ofARepositoryOpenedOnAWrittenStore_readsWhatIsThere() { + runBlocking { repository().update { it.copy(photoQuality = SOME_PHOTO_QUALITY) } } + + assertEquals(SOME_PHOTO_QUALITY, repository().settings.value.photoQuality) } @Test @@ -197,9 +204,9 @@ class SettingsRepositoryTest { val repository = repository() runBlocking { - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(true) - repository.selectMode(mode = MODE, isFrontFacing = false) + repository.modeSettings(SLOT) + repository.setGeoTagging(SLOT, true) + repository.modeSettings(SLOT) } assertEquals( @@ -209,31 +216,12 @@ class SettingsRepositoryTest { } @Test - fun writeMode_noModeSlotted_dropsTheWrite() { + fun modeSettings_exposesWhatTheMapperMadeOfTheStoredMode() { val repository = repository() modeSettingsMapper.result = MAPPER_RESULT - assertNull(runBlocking { repository.setGeoTagging(true) }) - assertNull(runBlocking { repository.setVideoQuality(Quality.UHD) }) - assertEquals(emptyList(), storedVideoQualityMapper.calls) - assertEquals(emptyMap(), stored().modes) - - runBlocking { - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(true) - } - - assertEquals(true, stored().modes[MODE.name]?.geoTagging) - } - - @Test - fun selectMode_exposesWhatTheMapperMadeOfTheStoredMode() { - val repository = repository() - - modeSettingsMapper.result = MAPPER_RESULT - - val selected = runBlocking { repository.selectMode(mode = MODE, isFrontFacing = true) } + val selected = runBlocking { repository.modeSettings(FRONT_SLOT) } assertEquals( FakeModeSettingsMapper.Call(stored = StoredModeSettings(), isFrontFacing = true), @@ -246,9 +234,9 @@ class SettingsRepositoryTest { fun setVideoQuality_storesTheNameTheMapperGaveIt() { val repository = repository() - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { repository.modeSettings(SLOT) } storedVideoQualityMapper.result = StoredVideoQuality.DEVICE_CHOICE - runBlocking { repository.setVideoQuality(Quality.HIGHEST) } + runBlocking { repository.setVideoQuality(SLOT, Quality.HIGHEST) } assertEquals(listOf(Quality.HIGHEST), storedVideoQualityMapper.calls) assertEquals( @@ -266,11 +254,11 @@ class SettingsRepositoryTest { storedVideoQualityMapper = StoredVideoQualityMapperImpl(), ) - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { repository.modeSettings(SLOT) } - val written = runBlocking { repository.setVideoQuality(Quality.LOWEST) } + val written = runBlocking { repository.setVideoQuality(SLOT, Quality.LOWEST) } - assertEquals(Quality.LOWEST, written?.videoQuality) + assertEquals(Quality.LOWEST, written.videoQuality) assertEquals( StoredVideoQuality.DEVICE_CHOICE, stored().modes[MODE.name]?.videoQualityBack, @@ -281,13 +269,13 @@ class SettingsRepositoryTest { fun setVideoQuality_eachLensFacing_isStoredSeparately() { val repository = repository() - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { repository.modeSettings(SLOT) } storedVideoQualityMapper.result = StoredVideoQuality.UHD - runBlocking { repository.setVideoQuality(Quality.UHD) } + runBlocking { repository.setVideoQuality(SLOT, Quality.UHD) } - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = true) } + runBlocking { repository.modeSettings(FRONT_SLOT) } storedVideoQualityMapper.result = StoredVideoQuality.HD - runBlocking { repository.setVideoQuality(Quality.HD) } + runBlocking { repository.setVideoQuality(FRONT_SLOT, Quality.HD) } val storedMode = stored().modes.getValue(MODE.name) @@ -296,16 +284,16 @@ class SettingsRepositoryTest { } @Test - fun selectMode_afterARelaunch_mapsWhatTheStoreHeld() { + fun modeSettings_afterARelaunch_mapsWhatTheStoreHeld() { val repository = repository() - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { repository.modeSettings(SLOT) } storedVideoQualityMapper.result = StoredVideoQuality.FHD - runBlocking { repository.setVideoQuality(Quality.FHD) } + runBlocking { repository.setVideoQuality(SLOT, Quality.FHD) } val relaunched = repository() - runBlocking { relaunched.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { relaunched.modeSettings(SLOT) } assertEquals( StoredModeSettings(videoQualityBack = StoredVideoQuality.FHD), @@ -318,14 +306,14 @@ class SettingsRepositoryTest { val repository = repository() runBlocking { - repository.selectMode(mode = MODE, isFrontFacing = false) - repository.setGeoTagging(true) - repository.selectMode(mode = OTHER_MODE, isFrontFacing = false) + repository.modeSettings(SLOT) + repository.setGeoTagging(SLOT, true) + repository.modeSettings(OTHER_SLOT) } assertEquals(StoredModeSettings(), modeSettingsMapper.calls.last().stored) - runBlocking { repository.selectMode(mode = MODE, isFrontFacing = false) } + runBlocking { repository.modeSettings(SLOT) } assertEquals(StoredModeSettings(geoTagging = true), modeSettingsMapper.calls.last().stored) } @@ -400,6 +388,10 @@ class SettingsRepositoryTest { val MODE = CameraMode.VIDEO val OTHER_MODE = CameraMode.CAMERA + val SLOT = ModeSlot(mode = MODE, isFrontFacing = false) + val FRONT_SLOT = ModeSlot(mode = MODE, isFrontFacing = true) + val OTHER_SLOT = ModeSlot(mode = OTHER_MODE, isFrontFacing = false) + val MAPPER_RESULT = ModeSettings(geoTagging = true, videoQuality = Quality.FHD) const val QR_CODE_FORMAT = "QR_CODE" diff --git a/app/src/test/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModuleTest.kt b/app/src/test/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModuleTest.kt new file mode 100644 index 000000000..32bf0c4bb --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModuleTest.kt @@ -0,0 +1,91 @@ +package app.grapheneos.camera.di.camera + +import android.app.Activity +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.ui.activities.CaptureActivity +import app.grapheneos.camera.ui.activities.MainActivity +import app.grapheneos.camera.ui.activities.QrTile +import app.grapheneos.camera.ui.activities.SecureCaptureActivity +import app.grapheneos.camera.ui.activities.SecureMainActivity +import app.grapheneos.camera.ui.activities.VideoCaptureActivity +import app.grapheneos.camera.ui.activities.VideoOnlyActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CameraEntryPointProvidesModuleTest { + + private val module = CameraEntryPointProvidesModule() + + private fun entryPointFor(type: Class): CameraEntryPoint { + return module.provideCameraEntryPoint(Robolectric.buildActivity(type).get()) + } + + @Test + fun theMainEntryPoint_offersEverything() { + assertEquals( + CameraEntryPoint( + isSecureSession = false, + isCaptureSession = false, + isVideoOnlySession = false, + requiresVideoModeOnly = false, + allowsQrScanning = true, + showsCameraModeTabs = true, + ), + entryPointFor(MainActivity::class.java), + ) + } + + @Test + fun aLockscreenSession_isSecureAndScansNoCodes() { + val entryPoint = entryPointFor(SecureMainActivity::class.java) + + assertTrue(entryPoint.isSecureSession) + assertFalse(entryPoint.allowsQrScanning) + assertTrue(entryPoint.showsCameraModeTabs) + } + + @Test + fun theQrTile_showsNoModeTabs() { + assertFalse(entryPointFor(QrTile::class.java).showsCameraModeTabs) + } + + @Test + fun aCaptureSession_showsNoModeTabsAndStillTakesPhotos() { + val entryPoint = entryPointFor(CaptureActivity::class.java) + + assertTrue(entryPoint.isCaptureSession) + assertFalse(entryPoint.showsCameraModeTabs) + assertFalse(entryPoint.requiresVideoModeOnly) + } + + @Test + fun aSecureCaptureSession_isBothSecureAndACapture() { + val entryPoint = entryPointFor(SecureCaptureActivity::class.java) + + assertTrue(entryPoint.isSecureSession) + assertTrue(entryPoint.isCaptureSession) + } + + @Test + fun aVideoCaptureSession_recordsOnly() { + val entryPoint = entryPointFor(VideoCaptureActivity::class.java) + + assertTrue(entryPoint.requiresVideoModeOnly) + assertFalse(entryPoint.isVideoOnlySession) + } + + @Test + fun aVideoOnlySession_recordsOnlyAndShowsNoModeTabs() { + val entryPoint = entryPointFor(VideoOnlyActivity::class.java) + + assertTrue(entryPoint.isVideoOnlySession) + assertTrue(entryPoint.requiresVideoModeOnly) + assertFalse(entryPoint.showsCameraModeTabs) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt b/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt index 109c02c90..00d8b7371 100644 --- a/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt +++ b/app/src/test/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModuleTest.kt @@ -22,6 +22,8 @@ class PreferencesProvidesModuleTest { private val module = PreferencesProvidesModule() + private val secureSession = SecureSessionPreferences() + private val durableSettings: DataStore = InMemoryDataStore( SettingsPrefs(common = StoredCameraSettings(photoQuality = OWNERS_PHOTO_QUALITY)), ) @@ -32,6 +34,7 @@ class PreferencesProvidesModuleTest { return module.provideSettingsPrefs( context = Robolectric.buildActivity(type).get(), durable = durableSettings, + secureSession = secureSession, ) } @@ -39,6 +42,7 @@ class PreferencesProvidesModuleTest { return module.provideStoragePrefs( context = Robolectric.buildActivity(type).get(), durable = durableStorage, + secureSession = secureSession, ) } diff --git a/app/src/test/java/app/grapheneos/camera/di/preferences/SecureSessionPreferencesTest.kt b/app/src/test/java/app/grapheneos/camera/di/preferences/SecureSessionPreferencesTest.kt new file mode 100644 index 000000000..cdbcdb37a --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/di/preferences/SecureSessionPreferencesTest.kt @@ -0,0 +1,108 @@ +package app.grapheneos.camera.di.preferences + +import androidx.datastore.core.DataStore +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import app.grapheneos.camera.data.settings.store.StoredCameraSettings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test + +class SecureSessionPreferencesTest { + + private val secureSession = SecureSessionPreferences() + + private val durable: DataStore = InMemoryDataStore( + SettingsPrefs(common = StoredCameraSettings(photoQuality = OWNERS_PHOTO_QUALITY)), + ) + + private fun photoQualityIn(store: DataStore): Int? { + return runBlocking { store.data.first() }.common.photoQuality + } + + private fun write(store: DataStore, quality: Int) { + runBlocking { + store.updateData { it.copy(common = it.common.copy(photoQuality = quality)) } + } + } + + @Test + fun everyActivityOfOneSession_sharesOneSnapshot() { + secureSession.onSecureActivityCreated() + val viewfinder = secureSession.settingsSnapshotOf(durable) + + secureSession.onSecureActivityCreated() + val settingsScreen = secureSession.settingsSnapshotOf(durable) + + assertSame(viewfinder, settingsScreen) + } + + @Test + fun aWriteInOneActivity_reachesTheOthersButNotTheOwner() { + secureSession.onSecureActivityCreated() + val settingsScreen = secureSession.settingsSnapshotOf(durable) + secureSession.onSecureActivityCreated() + val viewfinder = secureSession.settingsSnapshotOf(durable) + + write(settingsScreen, SESSIONS_PHOTO_QUALITY) + + assertEquals(SESSIONS_PHOTO_QUALITY, photoQualityIn(viewfinder)) + assertEquals(OWNERS_PHOTO_QUALITY, photoQualityIn(durable)) + } + + @Test + fun aSessionThatStillHasAnActivity_keepsItsSnapshot() { + secureSession.onSecureActivityCreated() + val viewfinder = secureSession.settingsSnapshotOf(durable) + secureSession.onSecureActivityCreated() + + // The settings screen of the same session closes; the viewfinder is still there. + secureSession.onSecureActivityDestroyed() + + assertSame(viewfinder, secureSession.settingsSnapshotOf(durable)) + } + + @Test + fun theNextSession_startsFromWhatTheOwnerStored() { + secureSession.onSecureActivityCreated() + write(secureSession.settingsSnapshotOf(durable), SESSIONS_PHOTO_QUALITY) + secureSession.onSecureActivityDestroyed() + + secureSession.onSecureActivityCreated() + val nextSession = secureSession.settingsSnapshotOf(durable) + + assertEquals(OWNERS_PHOTO_QUALITY, photoQualityIn(nextSession)) + } + + @Test + fun theNextSession_getsAStoreOfItsOwn() { + secureSession.onSecureActivityCreated() + val firstSession = secureSession.settingsSnapshotOf(durable) + secureSession.onSecureActivityDestroyed() + + secureSession.onSecureActivityCreated() + + assertNotSame(firstSession, secureSession.settingsSnapshotOf(durable)) + } + + @Test + fun anUnbalancedDestroy_doesNotStrandTheCounter() { + secureSession.onSecureActivityDestroyed() + + secureSession.onSecureActivityCreated() + val session = secureSession.settingsSnapshotOf(durable) + secureSession.onSecureActivityDestroyed() + secureSession.onSecureActivityCreated() + + assertNotSame(session, secureSession.settingsSnapshotOf(durable)) + } + + private companion object { + const val OWNERS_PHOTO_QUALITY = 70 + + const val SESSIONS_PHOTO_QUALITY = 95 + } +} diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt new file mode 100644 index 000000000..311d3eff5 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt @@ -0,0 +1,88 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.core.CameraSelector +import androidx.camera.extensions.ExtensionMode +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepositoryImpl +import app.grapheneos.camera.data.core.model.CameraMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ResolveAvailableModesImplTest { + + private val repository = ExtensionAvailabilityRepositoryImpl() + + @Test + fun invoke_aColdCache_offersOnlyTheModesThatNeedNoExtension() { + assertEquals( + setOf(CameraMode.QR_SCAN, CameraMode.CAMERA, CameraMode.VIDEO), + availableModes(allowsQrScanning = true, extensionsAvailable = true), + ) + } + + @Test + fun invoke_aLockedSession_withholdsQrScanning() { + val modes = availableModes(allowsQrScanning = false, extensionsAvailable = true) + + assertFalse(CameraMode.QR_SCAN in modes) + assertTrue(CameraMode.CAMERA in modes) + assertTrue(CameraMode.VIDEO in modes) + } + + @Test + fun invoke_anExtensionUsableOnOneLens_offersThatMode() { + repository.record(NIGHT_BACK, usable = true) + + assertTrue( + CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) + ) + } + + @Test + fun invoke_anExtensionUnusableOnBothLenses_withholdsThatMode() { + repository.record(NIGHT_FRONT, usable = false) + repository.record(NIGHT_BACK, usable = false) + + assertFalse( + CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) + ) + } + + @Test + fun invoke_noExtensionsManagerYet_withholdsEveryExtensionModeDespiteAWarmCache() { + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = true) + + assertEquals( + setOf(CameraMode.QR_SCAN, CameraMode.CAMERA, CameraMode.VIDEO), + availableModes(allowsQrScanning = true, extensionsAvailable = false), + ) + } + + private fun availableModes( + allowsQrScanning: Boolean, + extensionsAvailable: Boolean, + ): Set { + return ResolveAvailableModesImpl(repository).invoke( + allowsQrScanning = allowsQrScanning, + extensionsAvailable = extensionsAvailable, + ) + } + + private companion object { + val NIGHT_FRONT = ExtensionKey( + lensFacing = CameraSelector.LENS_FACING_FRONT, + extensionMode = ExtensionMode.NIGHT, + ) + + val NIGHT_BACK = ExtensionKey( + lensFacing = CameraSelector.LENS_FACING_BACK, + extensionMode = ExtensionMode.NIGHT, + ) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt new file mode 100644 index 000000000..0b5480d41 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt @@ -0,0 +1,95 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.core.CameraSelector +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import androidx.camera.video.Quality +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapperImpl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ResolveDroppedVideoQualityImplTest { + + private val resolve = ResolveDroppedVideoQualityImpl(VideoQualityFeatureMapperImpl()) + + private fun droppedQuality( + lensFacing: Int = BACK, + requestedQualityFeature: GroupableFeature? = QUALITY, + selected: Set = emptySet(), + ): Quality? { + return resolve( + lensFacing = lensFacing, + requestedQualityFeature = requestedQualityFeature, + selected = selected, + ) + } + + @Test + fun invoke_theQualityWasKept_reportsNothing() { + assertNull(droppedQuality(selected = setOf(QUALITY))) + } + + @Test + fun invoke_noQualityWasAskedFor_reportsNothing() { + assertNull(droppedQuality(requestedQualityFeature = null)) + } + + @Test + fun invoke_theQualityWasDropped_reportsIt() { + assertEquals(Quality.UHD, droppedQuality()) + } + + @Test + fun invoke_aFeatureThatNamesNoQualityWasDropped_reportsNothing() { + assertNull(droppedQuality(requestedQualityFeature = GroupableFeature.PREVIEW_STABILIZATION)) + } + + @Test + fun invoke_theSameLossTwiceOnOneCamera_reportsItOnce() { + assertEquals(Quality.UHD, droppedQuality()) + assertNull(droppedQuality()) + } + + @Test + fun invoke_theSameLossOnBothCameras_reportsItPerCamera() { + assertEquals(Quality.UHD, droppedQuality(lensFacing = BACK)) + assertEquals(Quality.UHD, droppedQuality(lensFacing = FRONT)) + } + + @Test + fun invoke_aSatisfiedBindOnTheOtherCamera_leavesThisCamerasLossRemembered() { + droppedQuality(lensFacing = BACK) + droppedQuality(lensFacing = FRONT, selected = setOf(QUALITY)) + + assertNull(droppedQuality(lensFacing = BACK)) + } + + @Test + fun invoke_aSatisfiedBindOnThisCamera_makesTheNextLossNewsAgain() { + droppedQuality() + droppedQuality(selected = setOf(QUALITY)) + + assertEquals(Quality.UHD, droppedQuality()) + } + + @Test + fun invoke_aDifferentQualityIsDropped_reportsItEvenOnTheSameCamera() { + assertEquals(Quality.UHD, droppedQuality()) + + assertEquals( + Quality.FHD, + droppedQuality(requestedQualityFeature = GroupableFeatures.FHD_RECORDING), + ) + } + + private companion object { + const val BACK = CameraSelector.LENS_FACING_BACK + const val FRONT = CameraSelector.LENS_FACING_FRONT + + val QUALITY: GroupableFeature = GroupableFeatures.UHD_RECORDING + } +} diff --git a/app/src/test/java/app/grapheneos/camera/domain/qr/BarcodeFormatsTest.kt b/app/src/test/java/app/grapheneos/camera/domain/qr/BarcodeFormatsTest.kt new file mode 100644 index 000000000..080dbe1f0 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/qr/BarcodeFormatsTest.kt @@ -0,0 +1,98 @@ +package app.grapheneos.camera.domain.qr + +import app.grapheneos.camera.data.core.store.InMemoryDataStore +import app.grapheneos.camera.data.settings.mapper.CameraSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.ModeSettingsMapperImpl +import app.grapheneos.camera.data.settings.mapper.StoredVideoQualityMapperImpl +import app.grapheneos.camera.data.settings.repository.SettingsRepositoryImpl +import app.grapheneos.camera.data.settings.store.SettingsPrefs +import com.google.zxing.BarcodeFormat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BarcodeFormatsTest { + + private val prefs = InMemoryDataStore(SettingsPrefs()) + + private val repository = SettingsRepositoryImpl( + dataStore = prefs, + cameraSettingsMapper = CameraSettingsMapperImpl(), + modeSettingsMapper = ModeSettingsMapperImpl(), + storedVideoQualityMapper = StoredVideoQualityMapperImpl(), + ) + + private val formats = BarcodeFormats(repository) + + private fun stored(): Set { + return runBlocking { repository.settings.first() }.enabledBarcodeFormats + } + + @Test + fun load_theStoredNames_becomeTheAllowedFormats() { + formats.load(setOf(BarcodeFormat.QR_CODE.name, BarcodeFormat.AZTEC.name)) + + assertEquals( + setOf(BarcodeFormat.AZTEC, BarcodeFormat.QR_CODE), + formats.enabled.toSet(), + ) + } + + @Test + fun setEnabled_aNewFormat_isAllowedAndStored() { + formats.load(setOf(BarcodeFormat.QR_CODE.name)) + + assertTrue(formats.setEnabled(BarcodeFormat.AZTEC.name, enabled = true)) + + assertTrue(BarcodeFormat.AZTEC in formats.enabled) + assertTrue(BarcodeFormat.AZTEC.name in stored()) + } + + @Test + fun setEnabled_disablingTheLastFormat_isRefused() { + formats.load(setOf(BarcodeFormat.QR_CODE.name)) + + assertFalse(formats.setEnabled(BarcodeFormat.QR_CODE.name, enabled = false)) + + assertTrue(BarcodeFormat.QR_CODE in formats.enabled) + } + + @Test + fun setEnabled_disablingOneOfSeveral_isAllowed() { + formats.load(setOf(BarcodeFormat.QR_CODE.name, BarcodeFormat.AZTEC.name)) + + assertTrue(formats.setEnabled(BarcodeFormat.AZTEC.name, enabled = false)) + + assertFalse(BarcodeFormat.AZTEC in formats.enabled) + } + + @Test + fun apply_aSelectionThatLeavesNothingToScan_isRefused() { + formats.load(emptySet()) + + assertFalse(formats.apply(formats.uncommonNames().associateWith { false })) + } + + @Test + fun apply_aSelectionWhileACommonFormatIsOn_isStored() { + formats.load(setOf(BarcodeFormat.QR_CODE.name)) + + assertTrue(formats.apply(mapOf(BarcodeFormat.CODE_128.name to true))) + + assertTrue(BarcodeFormat.CODE_128 in formats.enabled) + assertTrue(BarcodeFormat.CODE_128.name in stored()) + } + + @Test + fun uncommonNames_excludeTheFormatsWithTheirOwnToggles() { + val uncommon = formats.uncommonNames() + + BarcodeFormats.COMMON_FORMATS.forEach { assertFalse(it.name in uncommon) } + } +} diff --git a/app/src/test/java/app/grapheneos/camera/ui/CameraModeLabelTest.kt b/app/src/test/java/app/grapheneos/camera/ui/CameraModeLabelTest.kt new file mode 100644 index 000000000..1fa75d483 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/ui/CameraModeLabelTest.kt @@ -0,0 +1,33 @@ +package app.grapheneos.camera.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.grapheneos.camera.data.core.model.CameraMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CameraModeLabelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun cameraModeLabel_everyMode_resolvesToANonBlankString() { + CameraMode.entries.forEach { mode -> + assertTrue( + "$mode has a blank label", + context.getString(cameraModeLabel(mode)).isNotBlank(), + ) + } + } + + @Test + fun cameraModeLabel_everyMode_isLabelledDistinctly() { + val labels = CameraMode.entries.map { context.getString(cameraModeLabel(it)) } + + assertEquals(CameraMode.entries.size, labels.toSet().size) + } +} diff --git a/app/src/test/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderControllerTest.kt b/app/src/test/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderControllerTest.kt new file mode 100644 index 000000000..2ebe478ca --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderControllerTest.kt @@ -0,0 +1,126 @@ +package app.grapheneos.camera.ui.viewfinder.screen + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.Quality +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.data.settings.model.CameraSettings +import app.grapheneos.camera.data.settings.model.ModeSettings +import app.grapheneos.camera.data.settings.model.ModeSlot +import app.grapheneos.camera.data.settings.repository.SettingsRepository +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality +import app.grapheneos.camera.domain.gallery.usecase.RevertToMediaStoreLocation +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ViewfinderControllerTest { + + private val settingsRepository = RecordingSettingsRepository() + + private fun controller(): ViewfinderController { + return ViewfinderController( + entryPoint = ENTRY_POINT, + settingsRepository = settingsRepository, + resolveAvailableModes = NoModes(), + resolveDroppedVideoQuality = NoDroppedQuality(), + revertToMediaStoreLocation = NoRevert(), + ) + } + + @Test + fun perModeWrite_beforeAModeIsSlotted_isDropped() { + val controller = controller() + + controller.videoQuality = Quality.UHD + + assertTrue(settingsRepository.videoQualityWrites.isEmpty()) + } + + @Test + fun commonSettings_areReadThroughTheRepository() { + val controller = controller() + + settingsRepository.settings.value = CameraSettings(photoQuality = SOME_PHOTO_QUALITY) + + assertEquals(SOME_PHOTO_QUALITY, controller.photoQuality) + } + + private class RecordingSettingsRepository : SettingsRepository { + + override val settings = MutableStateFlow(CameraSettings()) + + val videoQualityWrites = mutableListOf>() + + override suspend fun update( + transform: (CameraSettings) -> CameraSettings, + ): CameraSettings { + settings.value = transform(settings.value) + + return settings.value + } + + override suspend fun modeSettings(slot: ModeSlot): ModeSettings { + return ModeSettings() + } + + override suspend fun setFlashMode(slot: ModeSlot, value: Int): ModeSettings { + return ModeSettings(flashMode = value) + } + + override suspend fun setGeoTagging(slot: ModeSlot, value: Boolean): ModeSettings { + return ModeSettings(geoTagging = value) + } + + override suspend fun setSelfIllumination(slot: ModeSlot, value: Boolean): ModeSettings { + return ModeSettings(selfIllumination = value) + } + + override suspend fun setVideoQuality(slot: ModeSlot, value: Quality): ModeSettings { + videoQualityWrites.add(slot to value) + + return ModeSettings(videoQuality = value) + } + } + + private class NoModes : ResolveAvailableModes { + override fun invoke( + allowsQrScanning: Boolean, + extensionsAvailable: Boolean, + ): Set { + return emptySet() + } + } + + private class NoDroppedQuality : ResolveDroppedVideoQuality { + override fun invoke( + lensFacing: Int, + requestedQualityFeature: GroupableFeature?, + selected: Set, + ): Quality? { + return null + } + } + + private class NoRevert : RevertToMediaStoreLocation { + override suspend fun invoke() = Unit + } + + private companion object { + val ENTRY_POINT = CameraEntryPoint( + isSecureSession = false, + isCaptureSession = false, + isVideoOnlySession = false, + requiresVideoModeOnly = false, + allowsQrScanning = true, + showsCameraModeTabs = true, + ) + + const val SOME_PHOTO_QUALITY = 71 + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 740438472..9e37fc9ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version. androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } junit4 = { module = "junit:junit", version.ref = "junit4" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 87caac6d5..a21bfa65d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -9597,6 +9597,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +