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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+