From e07f57c5db5dcd877e16f07ca9481b37dd0d9855 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 7 Aug 2026 13:14:49 +0300 Subject: [PATCH 01/23] Fix missing deps verification hashes --- gradle/verification-metadata.xml | 169 +++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 39c6f929d..dc7f88071 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -2834,6 +2834,17 @@ + + + + + + + + + + + @@ -2851,6 +2862,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3754,6 +3798,11 @@ + + + + + @@ -7787,6 +7836,17 @@ + + + + + + + + + + + @@ -7876,6 +7936,23 @@ + + + + + + + + + + + + + + + + + @@ -7893,6 +7970,20 @@ + + + + + + + + + + + + + + @@ -7944,6 +8035,23 @@ + + + + + + + + + + + + + + + + + @@ -7964,6 +8072,23 @@ + + + + + + + + + + + + + + + + + @@ -8097,6 +8222,17 @@ + + + + + + + + + + + @@ -8122,6 +8258,17 @@ + + + + + + + + + + + @@ -8246,6 +8393,17 @@ + + + + + + + + + + + @@ -8274,6 +8432,17 @@ + + + + + + + + + + + From 1660af7c4dc9050b0e99a17ba5632486cb370712 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 16:43:37 +0200 Subject: [PATCH 02/23] Cover bind time video quality decisions --- app/config/ktlint/baseline.xml | 345 +++++++++--------- .../java/app/grapheneos/camera/CamConfig.kt | 178 ++++----- .../camera/di/camera/CameraBindsModule.kt | 28 ++ .../di/camera/CameraSessionBindsModule.kt | 20 + .../mapper/VideoQualityFeatureMapper.kt | 36 ++ .../camera/model/InVideoSnapshotSupport.kt | 18 + .../usecase/ResolveDroppedVideoQuality.kt | 66 ++++ .../usecase/ResolveInVideoSnapshotSupport.kt | 64 ++++ .../camera/ui/activities/MainActivity.kt | 15 + .../VideoQualityFeatureMapperImplTest.kt | 54 +++ .../ResolveDroppedVideoQualityImplTest.kt | 95 +++++ .../ResolveInVideoSnapshotSupportImplTest.kt | 111 ++++++ 12 files changed, 745 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/camera/CameraSessionBindsModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapper.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 8b395637b..ac6d863a6 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,65 +114,64 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -606,121 +605,121 @@ - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 5098f5030..bcf706c03 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -59,6 +59,10 @@ 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.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality +import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.ktx.applyPreviewRatio import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity @@ -86,6 +90,9 @@ class CamConfig( private val mActivity: MainActivity, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, + private val videoQualityFeatureMapper: VideoQualityFeatureMapper, + private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, + private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, ) { companion object { @@ -131,7 +138,7 @@ class CamConfig( // 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() + private val snapshotSupport = 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. @@ -141,7 +148,7 @@ class CamConfig( @VisibleForTesting fun clearSnapshotProbeCache() { - snapshotDropReason.clear() + snapshotSupport.clear() snapshotProbeCount = 0 } @@ -805,7 +812,7 @@ class CamConfig( // extension verdicts probed through the previous instance (including bind-time // blacklists, see startCamera) describe vendor state that no longer exists. extensionUsability.clear() - snapshotDropReason.clear() + snapshotSupport.clear() probedCameraProvider = provider } cameraProvider = provider @@ -999,61 +1006,38 @@ class CamConfig( // 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 - } + 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 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 + 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 videoQualityTitle(mActivity, quality) + return cameraInfo?.let { + Recorder.getVideoCapabilities(it) + .getSupportedQualities(DynamicRange.SDR) + .firstOrNull() + } } - // 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 @@ -1069,32 +1053,18 @@ class CamConfig( // 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 - } + val droppedQuality = resolveDroppedVideoQuality( + lensFacing = boundLensFacing, + requestedQualityFeature = qualityFeature, + selected = selected, + ) ?: return - if (message != lastReportedDivergence[boundLensFacing]) { - lastReportedDivergence[boundLensFacing] = message - mActivity.showMessage(message) - } + mActivity.showMessage( + mActivity.getString( + R.string.quality_unsupported, + videoQualityTitle(mActivity, droppedQuality), + ) + ) } private fun isLensFacingSupported(lensFacing: Int): Boolean { @@ -1398,7 +1368,7 @@ class CamConfig( // 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 + // answer it, so the verdict is cached (snapshotSupport) and every entry into video mode // after the first is free. val snapshotUseCase = imageCapture if (isVideoMode && snapshotUseCase != null) { @@ -1406,7 +1376,7 @@ class CamConfig( lensFacing, videoQuality, usesFeatureGroup, captureMode, selectHighestResolution ) - if (!snapshotDropReason.containsKey(probeKey)) { + if (!snapshotSupport.containsKey(probeKey)) { snapshotProbeCount++ val cameraInfo = try { @@ -1417,43 +1387,27 @@ class CamConfig( 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" - } + snapshotSupport[probeKey] = resolveInVideoSnapshotSupport( + videoQualityFeature = videoQualityFeature, + probe = { withSnapshots, features -> + val probedUseCases = when { + withSnapshots -> useCasesList + else -> useCasesList - snapshotUseCase + } - isSupported(useCasesList, emptySet()) -> null - else -> "Video, photo and preview can't be bound together on this camera" - } + cameraInfo?.isSessionConfigSupported( + SessionConfig( + useCases = probedUseCases, + requiredFeatureGroup = features, + ) + ) == true + }, + ) } - val dropReason = snapshotDropReason[probeKey] - if (dropReason != null) { - Log.i(TAG, "$dropReason; disabling snapshots while recording") + val support = snapshotSupport[probeKey] + if (support is InVideoSnapshotSupport.Unsupported) { + Log.i(TAG, "${support.reason}; disabling snapshots while recording") useCasesList.remove(snapshotUseCase) imageCapture = null } 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..edb3cae33 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -0,0 +1,28 @@ +package app.grapheneos.camera.di.camera + +import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl +import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport +import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupportImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class CameraBindsModule { + + @Binds + @Reusable + abstract fun bindVideoQualityFeatureMapper( + impl: VideoQualityFeatureMapperImpl, + ): VideoQualityFeatureMapper + + @Binds + @Reusable + abstract fun bindResolveInVideoSnapshotSupport( + impl: ResolveInVideoSnapshotSupportImpl, + ): ResolveInVideoSnapshotSupport +} 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/domain/camera/mapper/VideoQualityFeatureMapper.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapper.kt new file mode 100644 index 000000000..245915954 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapper.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.domain.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/domain/camera/model/InVideoSnapshotSupport.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt new file mode 100644 index 000000000..7f966a06a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.domain.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/domain/camera/usecase/ResolveDroppedVideoQuality.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt new file mode 100644 index 000000000..035389eea --- /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.domain.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/camera/usecase/ResolveInVideoSnapshotSupport.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt new file mode 100644 index 000000000..41c183361 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt @@ -0,0 +1,64 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.core.featuregroup.GroupableFeature +import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +import javax.inject.Inject + +fun interface SessionProbe { + fun isSupported(withSnapshots: Boolean, features: Set): Boolean +} + +interface ResolveInVideoSnapshotSupport { + + operator fun invoke( + videoQualityFeature: GroupableFeature?, + probe: SessionProbe, + ): InVideoSnapshotSupport +} + +internal class ResolveInVideoSnapshotSupportImpl @Inject constructor() : + ResolveInVideoSnapshotSupport { + + override fun invoke( + 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/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 147daf737..993bba265 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 @@ -80,6 +80,9 @@ import app.grapheneos.camera.capturer.getVideoThumbnail 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.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality +import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.databinding.ActivityMainBinding import app.grapheneos.camera.databinding.ScanResultDialogBinding @@ -135,6 +138,15 @@ open class MainActivity : AppCompatActivity(), @Inject lateinit var capturedItemRepository: CapturedItemRepository + @Inject + lateinit var videoQualityFeatureMapper: VideoQualityFeatureMapper + + @Inject + lateinit var resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport + + @Inject + lateinit var resolveDroppedVideoQuality: ResolveDroppedVideoQuality + private val application: App get() = applicationContext as App @@ -788,6 +800,9 @@ open class MainActivity : AppCompatActivity(), mActivity = this, settingsRepository = settingsRepository, capturedItemRepository = capturedItemRepository, + videoQualityFeatureMapper = videoQualityFeatureMapper, + resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, + resolveDroppedVideoQuality = resolveDroppedVideoQuality, ) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt new file mode 100644 index 000000000..6672e7a6e --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt @@ -0,0 +1,54 @@ +package app.grapheneos.camera.domain.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/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQualityImplTest.kt new file mode 100644 index 000000000..48b1af88a --- /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.domain.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/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt new file mode 100644 index 000000000..86f018347 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt @@ -0,0 +1,111 @@ +package app.grapheneos.camera.domain.camera.usecase + +import androidx.camera.core.featuregroup.GroupableFeature +import androidx.camera.video.GroupableFeatures +import app.grapheneos.camera.domain.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 ResolveInVideoSnapshotSupportImplTest { + + private val resolve = ResolveInVideoSnapshotSupportImpl() + + @Test + fun invoke_noQualityAskedForAndTheStreamsBind_keepsSnapshots() { + val probe = FakeSessionProbe(supported = setOf(PLAIN_STREAMS_WITH_SNAPSHOTS)) + + val support = resolve(videoQualityFeature = null, probe = probe) + + assertEquals(InVideoSnapshotSupport.Supported, support) + } + + @Test + fun invoke_noQualityAskedForAndTheStreamsDoNotBind_dropsSnapshots() { + val probe = FakeSessionProbe(supported = emptySet()) + + val support = 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 = 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 = 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 = resolve(videoQualityFeature = QUALITY, probe = probe) + + assertEquals(InVideoSnapshotSupport.Supported, support) + } + + @Test + fun invoke_nothingBinds_dropsSnapshots() { + val probe = FakeSessionProbe(supported = emptySet()) + + val support = 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), + ) + } +} From 043120345bb9f7ad0b830dcc034b7a93940de8e6 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 16:58:02 +0200 Subject: [PATCH 03/23] Move extension availability verdicts --- app/config/ktlint/baseline.xml | 321 +++++++++--------- .../java/app/grapheneos/camera/CamConfig.kt | 83 ++--- .../camera/data/camera/model/ExtensionKey.kt | 6 + .../store/ExtensionAvailabilityStore.kt | 84 +++++ .../camera/di/camera/CameraBindsModule.kt | 9 + .../camera/ui/activities/MainActivity.kt | 5 + .../ExtensionAvailabilityStoreImplTest.kt | 109 ++++++ 7 files changed, 410 insertions(+), 207 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt create mode 100644 app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index ac6d863a6..740bd0a5f 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,64 +114,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + @@ -605,121 +608,121 @@ - - - - - - + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index bcf706c03..702f12274 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -50,6 +50,8 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.model.CameraSettings @@ -90,6 +92,7 @@ class CamConfig( private val mActivity: MainActivity, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, + private val extensionAvailabilityStore: ExtensionAvailabilityStore, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, @@ -128,12 +131,6 @@ class CamConfig( .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 @@ -811,7 +808,7 @@ class CamConfig( // 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() + extensionAvailabilityStore.clear() snapshotSupport.clear() probedCameraProvider = provider } @@ -874,10 +871,10 @@ class CamConfig( // 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 + // a faithful probe. Verdicts are cached (ExtensionAvailabilityStore) 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 @@ -886,7 +883,7 @@ class CamConfig( // 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 + // and must not be (see ExtensionAvailabilityStore). 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( @@ -941,8 +938,11 @@ class CamConfig( val em = extensionsManager ?: return false val provider = cameraProvider ?: return false - val key = lensFacing to extensionMode - extensionUsability[key]?.let { return it } + val key = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + extensionAvailabilityStore.verdict(key)?.let { return it } if (!probeOnMiss) return false @@ -955,7 +955,7 @@ class CamConfig( if (extensionProbesInFlight) return false val verdict = probeExtension(provider, em, selector, extensionMode) ?: return false - extensionUsability[key] = verdict + extensionAvailabilityStore.record(key, usable = verdict) return verdict } @@ -1148,11 +1148,14 @@ class CamConfig( cameraProvider?.unbindAll() val extMode = currentMode.extensionMode - var appliedExtension: Pair? = null + var appliedExtension: ExtensionKey? = null if (extMode != ExtensionMode.NONE) { val em = extensionsManager if (em != null && isExtensionUsable(cameraSelector, lensFacing, extMode)) { - appliedExtension = lensFacing to extMode + appliedExtension = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extMode, + ) cameraSelector = em.getExtensionEnabledCameraSelector(cameraSelector, extMode) } else { Log.e(TAG, "Mode $currentMode isn't available for this device") @@ -1463,7 +1466,7 @@ class CamConfig( } Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) - extensionUsability[key] = false + extensionAvailabilityStore.record(key, usable = false) mActivity.showMessage(mActivity.getString(R.string.extension_mode_unavailable)) // The bind never completed: currentMode still names the mode that was just disabled @@ -1583,27 +1586,17 @@ class CamConfig( }.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>> { + 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 extensionAvailabilityStore.unprobed() + } + + private fun selectorFor(lensFacing: Int): CameraSelector { + return when (lensFacing) { + CameraSelector.LENS_FACING_FRONT -> FRONT_CAMERA_SELECTOR + else -> REAR_CAMERA_SELECTOR } - return result } private var extensionProbesInFlight = false @@ -1634,9 +1627,11 @@ class CamConfig( extensionProbesInFlight = true thread { - val verdicts = HashMap, Boolean?>() - for ((selector, key) in pending) { - verdicts[key] = probeExtension(provider, em, selector, key.second) + val verdicts = HashMap() + for (key in pending) { + verdicts[key] = probeExtension( + provider, em, selectorFor(key.lensFacing), key.extensionMode + ) } ContextCompat.getMainExecutor(mActivity).execute { @@ -1652,15 +1647,7 @@ class CamConfig( 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 - } - } + extensionAvailabilityStore.recordProbeRound(verdicts) buildTabs() } } 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..465c4a9a0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt @@ -0,0 +1,6 @@ +package app.grapheneos.camera.data.camera.model + +data class ExtensionKey( + val lensFacing: Int, + val extensionMode: Int, +) diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt b/app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt new file mode 100644 index 000000000..ffd3b7aa0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt @@ -0,0 +1,84 @@ +package app.grapheneos.camera.data.camera.store + +import androidx.camera.core.CameraSelector +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 ExtensionAvailabilityStore { + + 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 ExtensionAvailabilityStoreImpl @Inject constructor() : ExtensionAvailabilityStore { + + // 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 (lensFacing in LENS_FACINGS) { + val key = ExtensionKey( + lensFacing = lensFacing, + extensionMode = mode.extensionMode, + ) + + if (usability[key] == null) { + result.add(key) + } + } + } + + return result + } + + override fun clear() { + usability.clear() + } + + private companion object { + val LENS_FACINGS = listOf( + CameraSelector.LENS_FACING_FRONT, + CameraSelector.LENS_FACING_BACK, + ) + } +} 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 index edb3cae33..ba7fb6c8f 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -1,5 +1,7 @@ package app.grapheneos.camera.di.camera +import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore +import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStoreImpl import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport @@ -9,11 +11,18 @@ 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 bindExtensionAvailabilityStore( + impl: ExtensionAvailabilityStoreImpl, + ): ExtensionAvailabilityStore + @Binds @Reusable abstract fun bindVideoQualityFeatureMapper( 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 993bba265..ff5aadef2 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 @@ -77,6 +77,7 @@ 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.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.repository.SettingsRepository @@ -138,6 +139,9 @@ open class MainActivity : AppCompatActivity(), @Inject lateinit var capturedItemRepository: CapturedItemRepository + @Inject + lateinit var extensionAvailabilityStore: ExtensionAvailabilityStore + @Inject lateinit var videoQualityFeatureMapper: VideoQualityFeatureMapper @@ -800,6 +804,7 @@ open class MainActivity : AppCompatActivity(), mActivity = this, settingsRepository = settingsRepository, capturedItemRepository = capturedItemRepository, + extensionAvailabilityStore = extensionAvailabilityStore, videoQualityFeatureMapper = videoQualityFeatureMapper, resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, resolveDroppedVideoQuality = resolveDroppedVideoQuality, diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt new file mode 100644 index 000000000..82a0b40e1 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.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.store.ExtensionAvailabilityStoreImpl +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 ExtensionAvailabilityStoreImplTest { + + private val store = ExtensionAvailabilityStoreImpl() + + @Test + fun verdict_anUnprobedKey_isUnknown() { + assertNull(store.verdict(NIGHT_FRONT)) + } + + @Test + fun record_aVerdict_readsBack() { + store.record(NIGHT_FRONT, usable = true) + store.record(NIGHT_BACK, usable = false) + + assertTrue(store.verdict(NIGHT_FRONT) == true) + assertFalse(store.verdict(NIGHT_BACK) == true) + } + + @Test + fun unprobed_aColdCache_listsEveryExtensionModeOnBothLenses() { + val extensionModes = CameraMode.entries + .map { it.extensionMode } + .filter { it != ExtensionMode.NONE } + .toSet() + + val unprobed = store.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(store.unprobed().none { it.extensionMode == ExtensionMode.NONE }) + } + + @Test + fun unprobed_anAnsweredKey_dropsOutOfTheList() { + store.record(NIGHT_FRONT, usable = false) + + assertFalse(NIGHT_FRONT in store.unprobed()) + assertTrue(NIGHT_BACK in store.unprobed()) + } + + @Test + fun recordProbeRound_aVerdictForAnUnprobedKey_isRemembered() { + store.recordProbeRound(mapOf(NIGHT_FRONT to true)) + + assertTrue(store.verdict(NIGHT_FRONT) == true) + } + + @Test + fun recordProbeRound_aTransientFailure_isNotRemembered() { + store.recordProbeRound(mapOf(NIGHT_FRONT to null)) + + assertNull(store.verdict(NIGHT_FRONT)) + } + + @Test + fun recordProbeRound_aKeyBlacklistedWhileTheRoundWasInFlight_staysBlacklisted() { + store.record(NIGHT_FRONT, usable = false) + store.recordProbeRound(mapOf(NIGHT_FRONT to true)) + + assertFalse(store.verdict(NIGHT_FRONT) == true) + } + + @Test + fun clear_aWarmCache_forgetsEveryVerdict() { + store.record(NIGHT_FRONT, usable = true) + store.record(NIGHT_BACK, usable = false) + + store.clear() + + assertNull(store.verdict(NIGHT_FRONT)) + assertNull(store.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, + ) + } +} From 9156ce39c6ff9fa196f7f072068532d58e01ba5a Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 17:16:38 +0200 Subject: [PATCH 04/23] Move camera provider outside CamConfig --- app/config/ktlint/baseline.xml | 305 +++++++++--------- app/detekt-baseline-debug.xml | 2 - .../java/app/grapheneos/camera/CamConfig.kt | 140 +++----- .../camera/repository/CameraProviderSource.kt | 91 ++++++ .../repository/FeatureCombinationSupport.kt | 54 ++++ .../camera/di/camera/CameraBindsModule.kt | 16 + .../camera/ui/activities/MainActivity.kt | 10 + 7 files changed, 363 insertions(+), 255 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 740bd0a5f..d5c907b8d 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -128,53 +128,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -608,121 +597,121 @@ - - - - - - - - - - + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index c25cdc152..10d736fd1 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -129,7 +129,6 @@ ReturnCount:App.kt:App$fun isAnyLocationProvideActive: 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 @@ -146,7 +145,6 @@ 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 SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception SwallowedException:CapturedItems.kt:e: ActivityNotFoundException diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 702f12274..d6e2d8f93 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -1,7 +1,6 @@ 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 @@ -18,8 +17,6 @@ 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 @@ -51,6 +48,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import app.grapheneos.camera.analyzer.QRAnalyzer import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.media.repository.CapturedItemRepository @@ -77,7 +76,6 @@ 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 @@ -92,7 +90,9 @@ class CamConfig( private val mActivity: MainActivity, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, + private val cameraProviderSource: CameraProviderSource, private val extensionAvailabilityStore: ExtensionAvailabilityStore, + private val featureCombinationSupport: FeatureCombinationSupport, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, @@ -793,72 +793,50 @@ class CamConfig( 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 - } + cameraProviderSource.acquireProvider(mActivity) { provider -> + when (provider) { + null -> mActivity.showMessage( + mActivity.getString(R.string.camera_provider_init_failure) + ) - 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. - extensionAvailabilityStore.clear() - snapshotSupport.clear() - probedCameraProvider = provider + else -> onCameraProviderReady(provider, forced) } - 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 - } + private fun onCameraProviderReady(provider: ProcessCameraProvider, forced: Boolean) { + 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. + extensionAvailabilityStore.clear() + snapshotSupport.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) - } - } + cameraProviderSource.acquireExtensionsManager(mActivity, provider) { manager -> + if (manager == null) { + mActivity.showMessage( + mActivity.getString(R.string.extensions_manager_init_failure) + ) + } else { + extensionsManager = manager } - }, ContextCompat.getMainExecutor(mActivity)) + startCamera(forced = forced) + } } // ExtensionsManager.isExtensionAvailable() answers from CameraExtensionCharacteristics' @@ -959,46 +937,18 @@ class CamConfig( 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 + cameraProvider?.getCameraInfo(cameraSelector) } 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 + return when (cameraInfo) { + null -> false + else -> featureCombinationSupport.canVerify(cameraInfo) + } } // Maps the user-chosen video quality to the equivalent groupable feature, for use when a 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..831c1e23d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt @@ -0,0 +1,91 @@ +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) + + providerFuture.addListener( + { + val provider = try { + providerFuture.get() + } catch (exception: ExecutionException) { + Log.e(TAG, "Camera provider initialization failed", exception) + null + } + + onResult(provider) + }, + 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) + + extensionsManagerFuture.addListener( + { + val extensionsManager = try { + extensionsManagerFuture.get() + } catch (exception: ExecutionException) { + Log.e(TAG, "Extensions manager future failed", exception) + null + } + + onResult(extensionsManager) + }, + 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/FeatureCombinationSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt new file mode 100644 index 000000000..df902eabc --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt @@ -0,0 +1,54 @@ +package app.grapheneos.camera.data.camera.repository + +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/di/camera/CameraBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt index ba7fb6c8f..d2dc04042 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -1,5 +1,9 @@ package app.grapheneos.camera.di.camera +import app.grapheneos.camera.data.camera.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.CameraProviderSourceImpl +import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport +import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupportImpl import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStoreImpl import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper @@ -23,6 +27,18 @@ internal abstract class CameraBindsModule { impl: ExtensionAvailabilityStoreImpl, ): ExtensionAvailabilityStore + @Binds + @Reusable + abstract fun bindCameraProviderSource( + impl: CameraProviderSourceImpl, + ): CameraProviderSource + + @Binds + @Reusable + abstract fun bindFeatureCombinationSupport( + impl: FeatureCombinationSupportImpl, + ): FeatureCombinationSupport + @Binds @Reusable abstract fun bindVideoQualityFeatureMapper( 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 ff5aadef2..3e30b23c3 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 @@ -77,6 +77,8 @@ 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.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.media.repository.CapturedItemRepository @@ -139,9 +141,15 @@ open class MainActivity : AppCompatActivity(), @Inject lateinit var capturedItemRepository: CapturedItemRepository + @Inject + lateinit var cameraProviderSource: CameraProviderSource + @Inject lateinit var extensionAvailabilityStore: ExtensionAvailabilityStore + @Inject + lateinit var featureCombinationSupport: FeatureCombinationSupport + @Inject lateinit var videoQualityFeatureMapper: VideoQualityFeatureMapper @@ -804,7 +812,9 @@ open class MainActivity : AppCompatActivity(), mActivity = this, settingsRepository = settingsRepository, capturedItemRepository = capturedItemRepository, + cameraProviderSource = cameraProviderSource, extensionAvailabilityStore = extensionAvailabilityStore, + featureCombinationSupport = featureCombinationSupport, videoQualityFeatureMapper = videoQualityFeatureMapper, resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, resolveDroppedVideoQuality = resolveDroppedVideoQuality, From afe2efeaa000363bf8f963d4466ad10c6330940e Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 18:25:53 +0200 Subject: [PATCH 05/23] Move camera session outside CamConfig --- app/config/ktlint/baseline.xml | 266 +++++++++--------- app/detekt-baseline-debug.xml | 1 - .../java/app/grapheneos/camera/CamConfig.kt | 196 ++++--------- .../camera/di/camera/CameraBindsModule.kt | 8 + .../domain/camera/model/CameraBindRequest.kt | 19 ++ .../domain/camera/model/CameraSessionPlan.kt | 15 + .../camera/model/FeatureGroupRequest.kt | 12 + .../domain/camera/model/ImageCaptureMode.kt | 7 + .../camera/usecase/BuildCameraSessionPlan.kt | 191 +++++++++++++ .../camera/ui/activities/MainActivity.kt | 5 + .../usecase/BuildCameraSessionPlanImplTest.kt | 174 ++++++++++++ 11 files changed, 613 insertions(+), 281 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index d5c907b8d..93c9ec912 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -131,39 +131,29 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -597,121 +587,121 @@ - - - - - - + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 10d736fd1..2a1d7d2f6 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -109,7 +109,6 @@ 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 diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index d6e2d8f93..323e9dd8c 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -24,25 +24,20 @@ 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 @@ -61,7 +56,11 @@ 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.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.model.CameraBindRequest +import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest +import app.grapheneos.camera.domain.camera.model.ImageCaptureMode import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.ktx.applyPreviewRatio @@ -93,6 +92,7 @@ class CamConfig( private val cameraProviderSource: CameraProviderSource, private val extensionAvailabilityStore: ExtensionAvailabilityStore, private val featureCombinationSupport: FeatureCombinationSupport, + private val buildCameraSessionPlan: BuildCameraSessionPlan, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, @@ -158,7 +158,7 @@ class CamConfig( val lensFacing: Int, val videoQuality: Quality, val usesFeatureGroup: Boolean, - val captureMode: Int, + val captureMode: ImageCaptureMode, val selectHighestResolution: Boolean, ) @@ -1088,8 +1088,6 @@ class CamConfig( .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() @@ -1114,10 +1112,6 @@ class CamConfig( 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 @@ -1134,24 +1128,44 @@ class CamConfig( // 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 + // 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 usesFeatureGroup = isVideoMode && enableEIS && canApplyVideoStabilization() - val videoQualityFeature: GroupableFeature? = when { - usesFeatureGroup -> videoQualityAsGroupableFeature() - else -> null + val featureGroup: FeatureGroupRequest = when { + isVideoMode && enableEIS && canApplyVideoStabilization() -> { + FeatureGroupRequest.Requested(videoQualityAsGroupableFeature()) + } + + else -> FeatureGroupRequest.Unused } - val captureMode = when { - waitForFocusLock -> ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY - enableZsl -> ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG - else -> ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY + val requiredQualityFeature = when (featureGroup) { + is FeatureGroupRequest.Requested -> featureGroup.videoQualityFeature + FeatureGroupRequest.Unused -> null } + val bindRequest = CameraBindRequest( + includesVideoCapture = !isQRMode && isVideoMode, + includesImageCapture = !isQRMode && !mActivity.requiresVideoModeOnly, + aspectRatio = aspectRatio, + imageCaptureTargetRotation = imageCapture?.targetRotation ?: rotation, + previewTargetRotation = preview?.targetRotation ?: rotation, + flashMode = flashMode, + photoQuality = photoQuality, + waitForFocusLock = waitForFocusLock, + enableZsl = enableZsl, + selectHighestResolution = selectHighestResolution, + videoQuality = videoQuality, + mirrorVideoOnFrontCamera = saveVideoAsPreviewed, + featureGroup = featureGroup, + ) + + val plan = buildCameraSessionPlan(bindRequest) + if (isQRMode) { val analyzer = QRAnalyzer(mActivity) val strategy = ResolutionStrategy( @@ -1182,136 +1196,30 @@ class CamConfig( } 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!!) + plan.videoCapture?.let { + videoCapture = it + useCasesList.add(it) } - } - 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) + plan.imageCapture?.let { + imageCapture = it + useCasesList.add(it) + } } - preview = previewBuilder.build().also { + preview = plan.preview.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 @@ -1326,7 +1234,11 @@ class CamConfig( val snapshotUseCase = imageCapture if (isVideoMode && snapshotUseCase != null) { val probeKey = SnapshotProbeKey( - lensFacing, videoQuality, usesFeatureGroup, captureMode, selectHighestResolution + lensFacing = lensFacing, + videoQuality = videoQuality, + usesFeatureGroup = featureGroup is FeatureGroupRequest.Requested, + captureMode = plan.captureMode, + selectHighestResolution = selectHighestResolution, ) if (!snapshotSupport.containsKey(probeKey)) { @@ -1341,7 +1253,7 @@ class CamConfig( } snapshotSupport[probeKey] = resolveInVideoSnapshotSupport( - videoQualityFeature = videoQualityFeature, + videoQualityFeature = requiredQualityFeature, probe = { withSnapshots, features -> val probedUseCases = when { withSnapshots -> useCasesList @@ -1369,16 +1281,16 @@ class CamConfig( try { val sessionConfig = SessionConfig( useCases = useCasesList, - preferredFeatureGroup = preferredFeatures + preferredFeatureGroup = plan.preferredFeatures ) - if (preferredFeatures.isNotEmpty()) { - val requested = preferredFeatures.toList() + if (plan.preferredFeatures.isNotEmpty()) { + val requested = plan.preferredFeatures.toList() val boundLensFacing = lensFacing sessionConfig.setFeatureSelectionListener( ContextCompat.getMainExecutor(mActivity) ) { selected -> - onFeaturesSelected(boundLensFacing, requested, videoQualityFeature, selected) + onFeaturesSelected(boundLensFacing, requested, requiredQualityFeature, selected) } } 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 index d2dc04042..3aa0e9663 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -8,6 +8,8 @@ import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStoreImpl import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl +import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan +import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlanImpl import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupportImpl import dagger.Binds @@ -45,6 +47,12 @@ internal abstract class CameraBindsModule { impl: VideoQualityFeatureMapperImpl, ): VideoQualityFeatureMapper + @Binds + @Reusable + abstract fun bindBuildCameraSessionPlan( + impl: BuildCameraSessionPlanImpl, + ): BuildCameraSessionPlan + @Binds @Reusable abstract fun bindResolveInVideoSnapshotSupport( diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt new file mode 100644 index 000000000..9db453541 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt @@ -0,0 +1,19 @@ +package app.grapheneos.camera.domain.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/domain/camera/model/CameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt new file mode 100644 index 000000000..74bc745b5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt @@ -0,0 +1,15 @@ +package app.grapheneos.camera.domain.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/domain/camera/model/FeatureGroupRequest.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt new file mode 100644 index 000000000..e2426e726 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt @@ -0,0 +1,12 @@ +package app.grapheneos.camera.domain.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/domain/camera/model/ImageCaptureMode.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt new file mode 100644 index 000000000..321a5fcd4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt @@ -0,0 +1,7 @@ +package app.grapheneos.camera.domain.camera.model + +enum class ImageCaptureMode { + MAXIMIZE_QUALITY, + ZERO_SHUTTER_LAG, + MINIMIZE_LATENCY, +} diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt new file mode 100644 index 000000000..85d1fd3b4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt @@ -0,0 +1,191 @@ +package app.grapheneos.camera.domain.camera.usecase + +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.domain.camera.model.CameraBindRequest +import app.grapheneos.camera.domain.camera.model.CameraSessionPlan +import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest +import app.grapheneos.camera.domain.camera.model.ImageCaptureMode +import javax.inject.Inject + +interface BuildCameraSessionPlan { + + operator fun invoke(request: CameraBindRequest): CameraSessionPlan +} + +@SuppressLint("UnsafeOptInUsageError", "RestrictedApi") +internal class BuildCameraSessionPlanImpl @Inject constructor() : BuildCameraSessionPlan { + + override fun invoke(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/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 3e30b23c3..c83da5deb 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 @@ -84,6 +84,7 @@ 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.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.shareCapturedItem @@ -150,6 +151,9 @@ open class MainActivity : AppCompatActivity(), @Inject lateinit var featureCombinationSupport: FeatureCombinationSupport + @Inject + lateinit var buildCameraSessionPlan: BuildCameraSessionPlan + @Inject lateinit var videoQualityFeatureMapper: VideoQualityFeatureMapper @@ -815,6 +819,7 @@ open class MainActivity : AppCompatActivity(), cameraProviderSource = cameraProviderSource, extensionAvailabilityStore = extensionAvailabilityStore, featureCombinationSupport = featureCombinationSupport, + buildCameraSessionPlan = buildCameraSessionPlan, videoQualityFeatureMapper = videoQualityFeatureMapper, resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, resolveDroppedVideoQuality = resolveDroppedVideoQuality, diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt new file mode 100644 index 000000000..55286db4c --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt @@ -0,0 +1,174 @@ +package app.grapheneos.camera.domain.camera.usecase + +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.domain.camera.model.CameraBindRequest +import app.grapheneos.camera.domain.camera.model.CameraSessionPlan +import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest +import app.grapheneos.camera.domain.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 BuildCameraSessionPlanImplTest { + + @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 BuildCameraSessionPlanImpl().invoke(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, + ) + } +} From bca02d683b207615b08a8c4134a1d39ef2ed04d3 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 18:58:15 +0200 Subject: [PATCH 06/23] Move camera mode list outside CamConfig --- app/config/ktlint/baseline.xml | 305 +++++++++--------- .../java/app/grapheneos/camera/CamConfig.kt | 39 +-- .../camera/di/camera/CameraBindsModule.kt | 8 + .../camera/usecase/ResolveAvailableModes.kt | 59 ++++ .../grapheneos/camera/ui/CameraModeLabel.kt | 19 ++ .../camera/ui/activities/MainActivity.kt | 5 + .../usecase/ResolveAvailableModesImplTest.kt | 85 +++++ .../camera/ui/CameraModeLabelTest.kt | 33 ++ 8 files changed, 377 insertions(+), 176 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModes.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/CameraModeLabel.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/ui/CameraModeLabelTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 93c9ec912..5e0ecc68d 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,46 +114,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -587,121 +583,121 @@ - - - - - - + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + @@ -817,4 +813,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 323e9dd8c..fcfa3c68a 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -53,6 +53,7 @@ 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.cameraModeLabel import app.grapheneos.camera.ui.videoQualityTitle import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper @@ -61,6 +62,7 @@ import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest import app.grapheneos.camera.domain.camera.model.ImageCaptureMode import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.ktx.applyPreviewRatio @@ -95,6 +97,7 @@ class CamConfig( private val buildCameraSessionPlan: BuildCameraSessionPlan, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, + private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, ) { @@ -1430,22 +1433,10 @@ class CamConfig( // 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() + return resolveAvailableModes( + allowsQrScanning = mActivity !is SecureMainActivity, + extensionsAvailable = extensionsManager != null && cameraProvider != null, + ) } private fun unprobedExtensions(): List { @@ -1515,20 +1506,6 @@ class CamConfig( } } - @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 @@ -1544,7 +1521,7 @@ class CamConfig( availableModes.forEach { mode -> tabLayout.newTab().let { tab -> - tab.setText(tabLabel(mode)) + tab.setText(cameraModeLabel(mode)) tab.view.setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_UP) { 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 index 3aa0e9663..85687a917 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -10,6 +10,8 @@ import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlanImpl +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModesImpl import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupportImpl import dagger.Binds @@ -53,6 +55,12 @@ internal abstract class CameraBindsModule { impl: BuildCameraSessionPlanImpl, ): BuildCameraSessionPlan + @Binds + @Reusable + abstract fun bindResolveAvailableModes( + impl: ResolveAvailableModesImpl, + ): ResolveAvailableModes + @Binds @Reusable abstract fun bindResolveInVideoSnapshotSupport( 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..2e3f88641 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModes.kt @@ -0,0 +1,59 @@ +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.store.ExtensionAvailabilityStore +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 extensionAvailabilityStore: ExtensionAvailabilityStore, +) : 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 LENS_FACINGS.any { lensFacing -> + val key = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + + extensionAvailabilityStore.verdict(key) == true + } + } + + private companion object { + private val LENS_FACINGS = listOf( + CameraSelector.LENS_FACING_FRONT, + CameraSelector.LENS_FACING_BACK, + ) + } +} 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/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index c83da5deb..98d950686 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 @@ -85,6 +85,7 @@ import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan +import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.shareCapturedItem @@ -160,6 +161,9 @@ open class MainActivity : AppCompatActivity(), @Inject lateinit var resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport + @Inject + lateinit var resolveAvailableModes: ResolveAvailableModes + @Inject lateinit var resolveDroppedVideoQuality: ResolveDroppedVideoQuality @@ -822,6 +826,7 @@ open class MainActivity : AppCompatActivity(), buildCameraSessionPlan = buildCameraSessionPlan, videoQualityFeatureMapper = videoQualityFeatureMapper, resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, + resolveAvailableModes = resolveAvailableModes, resolveDroppedVideoQuality = resolveDroppedVideoQuality, ) cameraControl = CameraControl(camConfig) 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..952e0cf38 --- /dev/null +++ b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt @@ -0,0 +1,85 @@ +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.store.ExtensionAvailabilityStoreImpl +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 store = ExtensionAvailabilityStoreImpl() + + + @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() { + store.record(NIGHT_BACK, usable = true) + + assertTrue(CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true)) + } + + @Test + fun invoke_anExtensionUnusableOnBothLenses_withholdsThatMode() { + store.record(NIGHT_FRONT, usable = false) + store.record(NIGHT_BACK, usable = false) + + assertFalse(CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true)) + } + + @Test + fun invoke_noExtensionsManagerYet_withholdsEveryExtensionModeDespiteAWarmCache() { + store.record(NIGHT_FRONT, usable = true) + store.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(store).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/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) + } +} From c5747d582e993636bf4f8f9a7e8e384a8684f40c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 19:43:41 +0200 Subject: [PATCH 07/23] Move view manipulation outside CamConfig --- app/config/ktlint/baseline.xml | 283 +++++++++--------- app/detekt-baseline-debug.xml | 4 - .../java/app/grapheneos/camera/CamConfig.kt | 279 ++++------------- .../grapheneos/camera/ui/BottomTabLayout.kt | 37 +++ .../grapheneos/camera/ui/QrFormatsDialog.kt | 26 ++ .../camera/ui/StorageLocationDialog.kt | 20 ++ .../camera/ui/activities/MainActivity.kt | 96 ++++++ 7 files changed, 364 insertions(+), 381 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/StorageLocationDialog.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 5e0ecc68d..6d7ce238d 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,42 +114,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -390,11 +376,11 @@ - - - - - + + + + + @@ -583,13 +569,12 @@ - - - - - - - + + + + + + @@ -597,107 +582,109 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 2a1d7d2f6..7a5494548 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -14,7 +14,6 @@ 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>${} @@ -131,7 +130,6 @@ 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:CapturedItems.kt:CapturedItems$fun parseCapturedItem: CapturedItem? ReturnCount:ImageCapturer.kt:ImageCapturer$@SuppressLint("RestrictedApi") fun takePicture ReturnCount:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails @@ -175,8 +173,6 @@ 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:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index fcfa3c68a..8d93c82c5 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -8,15 +8,9 @@ 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.core.AspectRatio import androidx.camera.core.Camera import androidx.camera.core.CameraInfo @@ -53,7 +47,8 @@ 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.cameraModeLabel +import app.grapheneos.camera.ui.showQrFormatsDialog +import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog import app.grapheneos.camera.ui.videoQualityTitle import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper @@ -68,13 +63,10 @@ import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport 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.Executors @@ -104,9 +96,6 @@ class CamConfig( 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( @@ -411,17 +400,7 @@ class CamConfig( } 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 - } + mActivity.applyScanAllCodesChrome(value) } qrAnalyzer?.refreshHints() @@ -1375,58 +1354,7 @@ class CamConfig( } 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) - } + mActivity.flashPreview(selfIlluminate) } // probeOnMiss is false because tab refreshes must never pay for a vendor probe on the main @@ -1506,36 +1434,12 @@ class CamConfig( } } - @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(cameraModeLabel(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) - } - } + mActivity.tabLayout.setModes( + modes = availableModes(), + currentMode = currentMode, + onTabTouched = mActivity::finalizeMode, + ) } fun switchMode(mode: CameraMode) { @@ -1551,48 +1455,11 @@ class CamConfig( 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() + mActivity.applyModeChrome( + mode = mode, + isVideoMode = isVideoMode, + scanAllCodes = scanAllCodes, + ) startCamera(true) @@ -1608,105 +1475,59 @@ class CamConfig( } 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) + val optionNames = BarcodeFormat.entries + .filterNot { it in commonFormats } + .map { it.name } + + showQrFormatsDialog( + activity = mActivity, + optionNames = optionNames, + initialValues = optionNames.map { it in settings.enabledBarcodeFormats }, + onConfirm = { values -> applyBarcodeFormats(optionNames, values) }, + ) + } - optionValues.add(format.name in settings.enabledBarcodeFormats) - } + private fun applyBarcodeFormats(optionNames: List, values: List) { + // 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 = commonFormats.none { allowedFormats.contains(it) } - builder.setMultiChoiceItems( - optionNames.toArray(arrayOf()), - optionValues.toBooleanArray() - ) { _, index, isChecked -> - optionValues[index] = isChecked + if (allCommonFormatsDisabled && values.none { it }) { + mActivity.showMessage(getString(R.string.no_barcode_selected)) + return } - // 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) - } - } + for ((index, optionName) in optionNames.withIndex()) { + val format = BarcodeFormat.valueOf(optionName) - settings = runBlocking { - settingsRepository.update { current -> - optionNames.foldIndexed(current) { index, updated, optionName -> - updated.withBarcodeFormat( - formatName = optionName, - enabled = optionValues[index], - ) - } + if (values[index]) { + if (format !in allowedFormats) { + allowedFormats.add(format) } + } else { + allowedFormats.remove(format) } - - 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 { - + settings = runBlocking { + settingsRepository.update { current -> + optionNames.foldIndexed(current) { index, updated, optionName -> + updated.withBarcodeFormat( + formatName = optionName, + enabled = values[index], + ) + } } } - dialog.showIgnoringShortEdgeMode() + qrAnalyzer?.refreshHints() } 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() + showStorageLocationNotFoundDialog(mActivity) } } 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/QrFormatsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt new file mode 100644 index 000000000..d614825e2 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt @@ -0,0 +1,26 @@ +package app.grapheneos.camera.ui + +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.activities.MainActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +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/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/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 98d950686..b9db36966 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 @@ -38,6 +38,7 @@ import android.view.View import android.view.View.OnTouchListener import android.view.ViewGroup import android.view.ViewTreeObserver +import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.LinearInterpolator import android.view.animation.RotateAnimation @@ -806,6 +807,98 @@ open class MainActivity : AppCompatActivity(), framePrefetchedAt = 0 } + + fun applyModeChrome( + mode: CameraMode, + isVideoMode: Boolean, + scanAllCodes: Boolean, + ) { + when (mode) { + CameraMode.QR_SCAN -> { + qrOverlay.visibility = View.VISIBLE + thirdOption.visibility = View.INVISIBLE + + applyScanAllCodesChrome(scanAllCodes) + + cancelButtonView.visibility = View.INVISIBLE + + captureButton.setBackgroundResource(android.R.color.transparent) + // Entering QR mode always leaves the torch off + setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) + + micOffIcon.visibility = View.GONE + } + + else -> { + qrOverlay.visibility = View.INVISIBLE + thirdOption.visibility = View.VISIBLE + setFlipCameraIcon(R.drawable.flip_camera, R.string.flip_camera) + cancelButtonView.visibility = View.VISIBLE + + qrScanToggles.visibility = View.GONE + + captureButton.setBackgroundResource(R.drawable.cbutton_bg) + + if (isVideoMode) { + setCaptureButtonIcon(R.drawable.recording, R.string.start_recording) + } else { + setCaptureButtonIcon(R.drawable.camera_shutter, R.string.capture) + micOffIcon.visibility = View.GONE + } + } + } + + updateSelfTimerBadge() + } + + fun applyScanAllCodesChrome(scanAllCodes: Boolean) { + if (scanAllCodes) { + setFlipCameraIcon(R.drawable.cancel, R.string.stop_scanning_all_formats) + qrScanToggles.visibility = View.GONE + } else { + setFlipCameraIcon(R.drawable.auto, R.string.scan_all_formats) + qrScanToggles.visibility = View.VISIBLE + } + } + + fun flashPreview(selfIlluminate: Boolean) { + val animation: Animation = when { + selfIlluminate -> AlphaAnimation(0f, 0.8f) + else -> AlphaAnimation(1f, 0f) + } + + animation.interpolator = LinearInterpolator() + + if (selfIlluminate) { + animation.duration = PREVIEW_SL_OVERLAY_DUR + animation.fillAfter = true + mainOverlay.setImageResource(android.R.color.white) + } else { + animation.duration = PREVIEW_SNAP_DURATION + animation.repeatMode = Animation.REVERSE + mainOverlay.setImageResource(android.R.color.black) + } + + animation.setAnimationListener( + object : Animation.AnimationListener { + override fun onAnimationStart(p0: Animation?) { + mainOverlay.visibility = View.VISIBLE + } + + override fun onAnimationEnd(p0: Animation?) { + if (!selfIlluminate) { + mainOverlay.visibility = View.INVISIBLE + mainOverlay.setImageResource(android.R.color.transparent) + } + } + + override fun onAnimationRepeat(p0: Animation?) {} + } + ) + + mainOverlay.startAnimation(animation) + } + lateinit var gestureDetector: GestureDetector @SuppressLint("ClickableViewAccessibility") @@ -1570,6 +1663,9 @@ open class MainActivity : AppCompatActivity(), private lateinit var cameraControl: CameraControl companion object { + private const val PREVIEW_SNAP_DURATION = 200L + private const val PREVIEW_SL_OVERLAY_DUR = 200L + private const val TAG = "GOCam" private const val autoCenterFocusDuration = 2000L private val hexArray = "0123456789ABCDEF".toCharArray() From 85d7a876e22893d12cc23ae1a155cbbe2ad737c8 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 20:04:54 +0200 Subject: [PATCH 08/23] Collapse repeated settings properties --- app/config/ktlint/baseline.xml | 192 +++++++-------- .../java/app/grapheneos/camera/CamConfig.kt | 231 ++++++------------ .../camera/ui/activities/MainActivity.kt | 13 + 3 files changed, 188 insertions(+), 248 deletions(-) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 6d7ce238d..f226e1802 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,28 +114,28 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -610,81 +610,81 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 8d93c82c5..723025a1d 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -9,7 +9,6 @@ import android.provider.MediaStore import android.util.Log import android.util.Size import android.view.View -import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.camera.core.AspectRatio import androidx.camera.core.Camera @@ -71,6 +70,8 @@ import com.google.zxing.BarcodeFormat import java.io.IOException import java.util.concurrent.Executors import kotlin.concurrent.thread +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -216,6 +217,21 @@ class CamConfig( private var modeSettings: ModeSettings = ModeSettings() + private fun setting( + read: (CameraSettings) -> T, + write: (CameraSettings, T) -> CameraSettings, + ): ReadWriteProperty { + return object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + return read(settings) + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + settings = runBlocking { settingsRepository.update { write(it, value) } } + } + } + } + private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) var lastCapturedItem: CapturedItem? = null @@ -323,15 +339,10 @@ class CamConfig( .requireLensFacing(DEFAULT_LENS_FACING) .build() - var gridType: GridType - get() { - return settings.gridType - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(gridType = value) } - } - } + var gridType: GridType by setting( + read = { it.gridType }, + write = { current, value -> current.copy(gridType = value) }, + ) var videoQuality: Quality get() { @@ -360,35 +371,20 @@ class CamConfig( mActivity.settingsDialog.updateFlashMode() } - var focusTimeout: Long - get() { - return settings.focusTimeoutSeconds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(focusTimeoutSeconds = value) } - } - } + var focusTimeout: Long by setting( + read = { it.focusTimeoutSeconds }, + write = { current, value -> current.copy(focusTimeoutSeconds = value) }, + ) - var selfTimerDuration: Int - get() { - return settings.selfTimerDurationSeconds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(selfTimerDurationSeconds = value) } - } - } + var selfTimerDuration: Int by setting( + read = { it.selfTimerDurationSeconds }, + write = { current, value -> current.copy(selfTimerDurationSeconds = value) }, + ) - var enableCameraSounds: Boolean - get() { - return settings.enableCameraSounds - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableCameraSounds = value) } - } - } + var enableCameraSounds: Boolean by setting( + read = { it.enableCameraSounds }, + write = { current, value -> current.copy(enableCameraSounds = value) }, + ) var scanAllCodes: Boolean get() { @@ -430,35 +426,20 @@ class CamConfig( mActivity.settingsDialog.enableEISToggle.isChecked = value } - var enableZsl: Boolean - get() { - return settings.enableZsl - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableZsl = value) } - } - } + var enableZsl: Boolean by setting( + read = { it.enableZsl }, + write = { current, value -> current.copy(enableZsl = value) }, + ) - var saveImageAsPreviewed: Boolean - get() { - return settings.saveImageAsPreviewed - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(saveImageAsPreviewed = value) } - } - } + var saveImageAsPreviewed: Boolean by setting( + read = { it.saveImageAsPreviewed }, + write = { current, value -> current.copy(saveImageAsPreviewed = value) }, + ) - var saveVideoAsPreviewed: Boolean - get() { - return settings.saveVideoAsPreviewed - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(saveVideoAsPreviewed = value) } - } - } + var saveVideoAsPreviewed: Boolean by setting( + read = { it.saveVideoAsPreviewed }, + write = { current, value -> current.copy(saveVideoAsPreviewed = value) }, + ) var storageLocation: String get() { @@ -473,35 +454,20 @@ class CamConfig( currentStorageLocation = value } - var photoQuality: Int - get() { - return settings.photoQuality - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(photoQuality = value) } - } - } + var photoQuality: Int by setting( + read = { it.photoQuality }, + write = { current, value -> current.copy(photoQuality = value) }, + ) - var removeExifAfterCapture: Boolean - get() { - return settings.removeExifAfterCapture - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(removeExifAfterCapture = value) } - } - } + var removeExifAfterCapture: Boolean by setting( + read = { it.removeExifAfterCapture }, + write = { current, value -> current.copy(removeExifAfterCapture = value) }, + ) - var gSuggestions: Boolean - get() { - return settings.gyroscopeSuggestions - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(gyroscopeSuggestions = value) } - } - } + var gSuggestions: Boolean by setting( + read = { it.gyroscopeSuggestions }, + write = { current, value -> current.copy(gyroscopeSuggestions = value) }, + ) val isZslSupported: Boolean by lazy { camera!!.cameraInfo.isZslSupported @@ -592,8 +558,6 @@ class CamConfig( mActivity.settingsDialog.selfIllumination() } - private fun getString(@StringRes id: Int) = mActivity.getString(id) - fun setQRScanningFor(format: String, selected: Boolean) { settings = runBlocking { @@ -608,9 +572,7 @@ class CamConfig( } } else { if (allowedFormats.size == 1) { - mActivity.showMessage( - getString(R.string.no_barcode_selected) - ) + mActivity.showMessage(R.string.no_barcode_selected) } else { allowedFormats.remove(BarcodeFormat.valueOf(format)) } @@ -659,53 +621,24 @@ class CamConfig( enableEIS = settings.enableEis allowedFormats.clear() + allowedFormats.addAll( + BarcodeFormat.entries.filter { it.name in settings.enabledBarcodeFormats }, + ) - 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 - } - } - } + mActivity.selectBarcodeFormatToggles(allowedFormats) qrAnalyzer?.refreshHints() } - var waitForFocusLock: Boolean - get() { - return settings.waitForFocusLock - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(waitForFocusLock = value) } - } - } + var waitForFocusLock: Boolean by setting( + read = { it.waitForFocusLock }, + write = { current, value -> current.copy(waitForFocusLock = value) }, + ) - var selectHighestResolution: Boolean - get() { - return settings.selectHighestResolution - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(selectHighestResolution = value) } - } - } + var selectHighestResolution: Boolean by setting( + read = { it.selectHighestResolution }, + write = { current, value -> current.copy(selectHighestResolution = value) }, + ) fun toggleTorchState() { isTorchOn = !isTorchOn @@ -723,9 +656,7 @@ class CamConfig( setFlashMode(next) } else { - mActivity.showMessage( - getString(R.string.flash_unavailable_in_selected_mode) - ) + mActivity.showMessage(R.string.flash_unavailable_in_selected_mode) } } @@ -760,10 +691,10 @@ class CamConfig( // 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)) + mActivity.showMessage(R.string.rear_camera_unavailable) CameraSelector.LENS_FACING_FRONT } else { - mActivity.showMessage(getString(R.string.front_camera_unavailable)) + mActivity.showMessage(R.string.front_camera_unavailable) CameraSelector.LENS_FACING_BACK } } @@ -778,9 +709,7 @@ class CamConfig( cameraProviderSource.acquireProvider(mActivity) { provider -> when (provider) { - null -> mActivity.showMessage( - mActivity.getString(R.string.camera_provider_init_failure) - ) + null -> mActivity.showMessage(R.string.camera_provider_init_failure) else -> onCameraProviderReady(provider, forced) } @@ -810,9 +739,7 @@ class CamConfig( cameraProviderSource.acquireExtensionsManager(mActivity, provider) { manager -> if (manager == null) { - mActivity.showMessage( - mActivity.getString(R.string.extensions_manager_init_failure) - ) + mActivity.showMessage(R.string.extensions_manager_init_failure) } else { extensionsManager = manager } @@ -1230,7 +1157,7 @@ class CamConfig( cameraProvider?.getCameraInfo(cameraSelector) } catch (exception: IllegalArgumentException) { Log.e(TAG, "Failed to query camera info", exception) - mActivity.showMessage(mActivity.getString(R.string.bind_failure)) + mActivity.showMessage(R.string.bind_failure) return } @@ -1292,7 +1219,7 @@ class CamConfig( // stay visible. if (exception is IllegalArgumentException) { Log.e(TAG, "Failed to bind use cases", exception) - mActivity.showMessage(mActivity.getString(R.string.bind_failure)) + mActivity.showMessage(R.string.bind_failure) return } throw exception @@ -1311,7 +1238,7 @@ class CamConfig( Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) extensionAvailabilityStore.record(key, usable = false) - mActivity.showMessage(mActivity.getString(R.string.extension_mode_unavailable)) + mActivity.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 @@ -1494,7 +1421,7 @@ class CamConfig( val allCommonFormatsDisabled = commonFormats.none { allowedFormats.contains(it) } if (allCommonFormatsDisabled && values.none { it }) { - mActivity.showMessage(getString(R.string.no_barcode_selected)) + mActivity.showMessage(R.string.no_barcode_selected) return } 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 b9db36966..1bdf1e532 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 @@ -808,6 +808,19 @@ open class MainActivity : AppCompatActivity(), } + fun selectBarcodeFormatToggles(formats: List) { + val toggles = mapOf( + BarcodeFormat.QR_CODE to qrToggle, + BarcodeFormat.AZTEC to azToggle, + BarcodeFormat.PDF_417 to cBToggle, + BarcodeFormat.DATA_MATRIX to dmToggle, + ) + + formats.forEach { format -> + toggles[format]?.isSelected = true + } + } + fun applyModeChrome( mode: CameraMode, isVideoMode: Boolean, From e004071ea80b427aa9ae8fb0f9c1fcac04db6ae2 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 20:27:01 +0200 Subject: [PATCH 09/23] Build CamConfig through factory --- app/config/ktlint/baseline.xml | 276 +++++++++--------- .../java/app/grapheneos/camera/CamConfig.kt | 88 +++--- .../camera/ui/activities/MainActivity.kt | 172 ++++++++--- 3 files changed, 298 insertions(+), 238 deletions(-) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index f226e1802..01c3adf8b 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,28 +114,27 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -569,122 +568,125 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 723025a1d..e510fbc40 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -2,13 +2,11 @@ package app.grapheneos.camera import android.annotation.SuppressLint 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.View import androidx.annotation.VisibleForTesting import androidx.camera.core.AspectRatio import androidx.camera.core.Camera @@ -59,7 +57,6 @@ import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport -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.SecureActivity @@ -67,6 +64,9 @@ import app.grapheneos.camera.ui.activities.SecureMainActivity import app.grapheneos.camera.ui.activities.VideoCaptureActivity import app.grapheneos.camera.ui.activities.VideoOnlyActivity import com.google.zxing.BarcodeFormat +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import java.io.IOException import java.util.concurrent.Executors import kotlin.concurrent.thread @@ -80,8 +80,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") -class CamConfig( - private val mActivity: MainActivity, +class CamConfig @AssistedInject constructor( + @Assisted private val mActivity: MainActivity, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, private val cameraProviderSource: CameraProviderSource, @@ -94,6 +94,11 @@ class CamConfig( private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, ) { + @AssistedFactory + interface Factory { + fun create(activity: MainActivity): CamConfig + } + companion object { private const val TAG = "CamConfig" @@ -170,14 +175,14 @@ class CamConfig( private val zoomStateObserver = Observer { zoomState = it if (it.linearZoom != 0f || it.zoomRatio != 1f) { - mActivity.zoomBar.updateThumb() + mActivity.updateZoomThumb() } } private val handler = Handler(Looper.getMainLooper()) private val attachZoomState = Runnable { - if (mActivity.isDestroyed || mActivity.isFinishing) return@Runnable + if (!mActivity.isSessionActive) return@Runnable zoomStateSource = camera?.cameraInfo?.zoomState?.also { it.observe(mActivity, zoomStateObserver) @@ -368,7 +373,7 @@ class CamConfig( private fun applyFlashMode(value: Int) { flashMode = value imageCapture?.flashMode = value - mActivity.settingsDialog.updateFlashMode() + mActivity.onFlashModeChanged() } var focusTimeout: Long by setting( @@ -411,7 +416,7 @@ class CamConfig( settingsRepository.update { it.copy(includeAudio = value) } } - mActivity.settingsDialog.includeAudioToggle.isChecked = value + mActivity.onIncludeAudioChanged(value) } var enableEIS: Boolean @@ -423,7 +428,7 @@ class CamConfig( settingsRepository.update { it.copy(enableEis = value) } } - mActivity.settingsDialog.enableEISToggle.isChecked = value + mActivity.onEnableEisChanged(value) } var enableZsl: Boolean by setting( @@ -539,7 +544,7 @@ class CamConfig( settingsRepository.setGeoTagging(value) }?.let { modeSettings = it } - mActivity.settingsDialog.locToggle.isChecked = value + mActivity.onGeoTaggingChanged(value) field = value } @@ -554,8 +559,7 @@ class CamConfig( settingsRepository.setSelfIllumination(value) }?.let { modeSettings = it } - mActivity.settingsDialog.selfIlluminationToggle.isChecked = value - mActivity.settingsDialog.selfIllumination() + mActivity.onSelfIlluminationChanged(value) } fun setQRScanningFor(format: String, selected: Boolean) { @@ -594,7 +598,7 @@ class CamConfig( slotCurrentMode() if (isVideoMode) { - mActivity.settingsDialog.reloadQualities() + mActivity.reloadVideoQualities() } applyFlashMode(modeSettings.flashMode) @@ -608,13 +612,13 @@ class CamConfig( selfIlluminate = modeSettings.selfIllumination - mActivity.settingsDialog.showOnlyRelevantSettings() + mActivity.showOnlyRelevantSettings() } fun loadSettings() { - mActivity.settingsDialog.updateGridToggleUI() + mActivity.onGridTypeChanged() - mActivity.settingsDialog.updateFocusTimeout(focusTimeoutLabel(settings.focusTimeoutSeconds)) + mActivity.onFocusTimeoutChanged(focusTimeoutLabel(settings.focusTimeoutSeconds)) includeAudio = settings.includeAudio @@ -956,28 +960,18 @@ class CamConfig( if ((!forced && camera != null) || cameraProvider == null) return // Cancel any pending capture requests - mActivity.imageCapturer.cancelPendingCaptureRequest() + mActivity.cancelPendingCapture() - mActivity.exposureBar.hidePanel() + mActivity.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 = 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 - } + val rotation = mActivity.displayRotation - if (mActivity.isDestroyed || mActivity.isFinishing) return + if (!mActivity.isSessionActive) return // Test whether the current lens facing is supported by the current device // If not then silently switch to the other lens facing @@ -1105,10 +1099,7 @@ class CamConfig( } else { if (isVideoMode) { - mActivity.micOffIcon.visibility = when { - includeAudio -> View.GONE - else -> View.VISIBLE - } + mActivity.setMicMutedIconVisible(!includeAudio) } plan.videoCapture?.let { @@ -1124,7 +1115,7 @@ class CamConfig( preview = plan.preview.also { useCasesList.add(it) - it.surfaceProvider = mActivity.previewView.surfaceProvider + it.surfaceProvider = mActivity.previewSurfaceProvider } mActivity.forceUpdateOrientationSensor() @@ -1263,21 +1254,15 @@ class CamConfig( handler.removeCallbacks(attachZoomState) handler.post(attachZoomState) - mActivity.zoomBar.updateThumb(false) + mActivity.updateZoomThumb(false) - camera?.cameraInfo?.exposureState?.let { mActivity.exposureBar.setExposureConfig(it) } + camera?.cameraInfo?.exposureState?.let { mActivity.applyExposureState(it) } - mActivity.settingsDialog.torchToggle.isChecked = false + mActivity.resetTorchToggle() - // Focus camera on touch/tap - mActivity.previewView.setOnTouchListener(mActivity) - camera?.cameraInfo?.let { mActivity.previewView.applyPreviewRatio(aspectRatio, it) } + camera?.cameraInfo?.let { mActivity.onPreviewBound(aspectRatio, it) } - if (isInPhotoMode) { - mActivity.sensorNotifier?.forceUpdateGyro() - } else { - mActivity.gCircleFrame.visibility = View.GONE - } + mActivity.updateGyroscopeIndicator(isInPhotoMode) } fun snapPreview() { @@ -1344,7 +1329,7 @@ class CamConfig( ContextCompat.getMainExecutor(mActivity).execute { extensionProbesInFlight = false - if (mActivity.isDestroyed || mActivity.isFinishing) return@execute + if (!mActivity.isSessionActive) return@execute if (probedCameraProvider !== provider) { // The camera stack was reinitialized while probing: these verdicts describe @@ -1362,10 +1347,9 @@ class CamConfig( } private fun buildTabs() { - mActivity.tabLayout.setModes( + mActivity.setCameraModeTabs( modes = availableModes(), currentMode = currentMode, - onTabTouched = mActivity::finalizeMode, ) } @@ -1395,9 +1379,7 @@ class CamConfig( // 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) - } + mActivity.goToModeTab(currentMode) } } 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 1bdf1e532..5314a71d7 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 @@ -56,8 +56,11 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraInfo +import androidx.camera.core.ExposureState import androidx.camera.core.FocusMeteringAction import androidx.camera.core.MeteringPointFactory +import androidx.camera.core.Preview import androidx.camera.core.SurfaceOrientedMeteringPointFactory import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView.StreamState @@ -78,17 +81,8 @@ 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.repository.CameraProviderSource -import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport -import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore 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.domain.camera.mapper.VideoQualityFeatureMapper -import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan -import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes -import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality -import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import app.grapheneos.camera.shareCapturedItem import app.grapheneos.camera.databinding.ActivityMainBinding import app.grapheneos.camera.databinding.ScanResultDialogBinding @@ -139,34 +133,12 @@ open class MainActivity : AppCompatActivity(), SensorOrientationChangeNotifier.Listener { @Inject - lateinit var settingsRepository: SettingsRepository - - @Inject - lateinit var capturedItemRepository: CapturedItemRepository - - @Inject - lateinit var cameraProviderSource: CameraProviderSource + lateinit var camConfigFactory: CamConfig.Factory @Inject - lateinit var extensionAvailabilityStore: ExtensionAvailabilityStore - - @Inject - lateinit var featureCombinationSupport: FeatureCombinationSupport - - @Inject - lateinit var buildCameraSessionPlan: BuildCameraSessionPlan - - @Inject - lateinit var videoQualityFeatureMapper: VideoQualityFeatureMapper - - @Inject - lateinit var resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport - - @Inject - lateinit var resolveAvailableModes: ResolveAvailableModes + lateinit var settingsRepository: SettingsRepository - @Inject - lateinit var resolveDroppedVideoQuality: ResolveDroppedVideoQuality + lateinit var camConfig: CamConfig private val application: App get() = applicationContext as App @@ -808,6 +780,123 @@ open class MainActivity : AppCompatActivity(), } + val isSessionActive: Boolean + get() { + return !isDestroyed && !isFinishing + } + + val previewSurfaceProvider: Preview.SurfaceProvider + get() { + return previewView.surfaceProvider + } + + val displayRotation: Int + get() { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + display?.rotation ?: @Suppress("DEPRECATION") + windowManager.defaultDisplay.rotation + } + + // 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 -> @Suppress("DEPRECATION") windowManager.defaultDisplay.rotation + } + } + + fun cancelPendingCapture() { + imageCapturer.cancelPendingCaptureRequest() + } + + fun hideExposurePanel() { + exposureBar.hidePanel() + } + + fun applyExposureState(exposureState: ExposureState) { + exposureBar.setExposureConfig(exposureState) + } + + fun updateZoomThumb(shouldShowPanel: Boolean = true) { + zoomBar.updateThumb(shouldShowPanel) + } + + fun setMicMutedIconVisible(visible: Boolean) { + micOffIcon.visibility = when { + visible -> View.VISIBLE + else -> View.GONE + } + } + + fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) { + // Focus camera on touch/tap + previewView.setOnTouchListener(this) + previewView.applyPreviewRatio(aspectRatio, cameraInfo) + } + + fun updateGyroscopeIndicator(inPhotoMode: Boolean) { + if (inPhotoMode) { + sensorNotifier?.forceUpdateGyro() + } else { + gCircleFrame.visibility = View.GONE + } + } + + fun setCameraModeTabs(modes: Set, currentMode: CameraMode) { + tabLayout.setModes( + modes = modes, + currentMode = currentMode, + onTabTouched = ::finalizeMode, + ) + } + + fun goToModeTab(mode: CameraMode) { + tabLayout.getTabForMode(mode)?.let { tab -> + tabLayout.goToTab(tab) + } + } + + fun onFlashModeChanged() { + settingsDialog.updateFlashMode() + } + + fun onGridTypeChanged() { + settingsDialog.updateGridToggleUI() + } + + fun onFocusTimeoutChanged(label: String) { + settingsDialog.updateFocusTimeout(label) + } + + fun onIncludeAudioChanged(enabled: Boolean) { + settingsDialog.includeAudioToggle.isChecked = enabled + } + + fun onEnableEisChanged(enabled: Boolean) { + settingsDialog.enableEISToggle.isChecked = enabled + } + + fun onGeoTaggingChanged(enabled: Boolean) { + settingsDialog.locToggle.isChecked = enabled + } + + fun onSelfIlluminationChanged(enabled: Boolean) { + settingsDialog.selfIlluminationToggle.isChecked = enabled + settingsDialog.selfIllumination() + } + + fun reloadVideoQualities() { + settingsDialog.reloadQualities() + } + + fun showOnlyRelevantSettings() { + settingsDialog.showOnlyRelevantSettings() + } + + fun resetTorchToggle() { + settingsDialog.torchToggle.isChecked = false + } + fun selectBarcodeFormatToggles(formats: List) { val toggles = mapOf( BarcodeFormat.QR_CODE to qrToggle, @@ -922,19 +1011,7 @@ open class MainActivity : AppCompatActivity(), gestureDetector = GestureDetector(this, this) - camConfig = CamConfig( - mActivity = this, - settingsRepository = settingsRepository, - capturedItemRepository = capturedItemRepository, - cameraProviderSource = cameraProviderSource, - extensionAvailabilityStore = extensionAvailabilityStore, - featureCombinationSupport = featureCombinationSupport, - buildCameraSessionPlan = buildCameraSessionPlan, - videoQualityFeatureMapper = videoQualityFeatureMapper, - resolveInVideoSnapshotSupport = resolveInVideoSnapshotSupport, - resolveAvailableModes = resolveAvailableModes, - resolveDroppedVideoQuality = resolveDroppedVideoQuality, - ) + camConfig = camConfigFactory.create(this) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay imageCapturer = ImageCapturer(this) @@ -1672,7 +1749,6 @@ open class MainActivity : AppCompatActivity(), rotateView(muteToggle, iconRotation) } - lateinit var camConfig: CamConfig private lateinit var cameraControl: CameraControl companion object { From 6a447e93cba3c8835c138633282fccb7e511942e Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 21:13:56 +0200 Subject: [PATCH 10/23] Inject camera entry point --- app/config/ktlint/baseline.xml | 40 ++++---- .../java/app/grapheneos/camera/CamConfig.kt | 25 ++--- .../camera/CameraEntryPointProvidesModule.kt | 37 ++++++++ .../domain/camera/model/CameraEntryPoint.kt | 10 ++ .../CameraEntryPointProvidesModuleTest.kt | 91 +++++++++++++++++++ 5 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraEntryPoint.kt create mode 100644 app/src/test/java/app/grapheneos/camera/di/camera/CameraEntryPointProvidesModuleTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 01c3adf8b..c0d9e63d0 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,27 +114,25 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index e510fbc40..644960a11 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -50,6 +50,7 @@ import app.grapheneos.camera.ui.videoQualityTitle import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.model.CameraBindRequest +import app.grapheneos.camera.domain.camera.model.CameraEntryPoint import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest import app.grapheneos.camera.domain.camera.model.ImageCaptureMode import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport @@ -57,12 +58,7 @@ import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport -import app.grapheneos.camera.ui.activities.CaptureActivity import app.grapheneos.camera.ui.activities.MainActivity -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 com.google.zxing.BarcodeFormat import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -82,6 +78,7 @@ import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") class CamConfig @AssistedInject constructor( @Assisted private val mActivity: MainActivity, + private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, private val cameraProviderSource: CameraProviderSource, @@ -250,7 +247,7 @@ class CamConfig @AssistedInject constructor( capturedItemRepository.storageLocation.collect { currentStorageLocation = it } } - if (mActivity !is SecureActivity) { + if (!entryPoint.isSecureSession) { try { runBlocking { capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) @@ -275,7 +272,7 @@ class CamConfig @AssistedInject constructor( Log.e(TAG, "unable to read the last captured item", e) null } - val skip = item?.type == ITEM_TYPE_IMAGE && mActivity is VideoOnlyActivity + val skip = item?.type == ITEM_TYPE_IMAGE && entryPoint.isVideoOnlySession lastCapturedItem = if (skip) null else item } @@ -284,9 +281,7 @@ class CamConfig @AssistedInject constructor( var isVideoMode = false private set get() { - return field || - mActivity is VideoCaptureActivity || - mActivity is VideoOnlyActivity + return field || entryPoint.requiresVideoModeOnly } val canTakePicture: Boolean @@ -518,7 +513,7 @@ class CamConfig @AssistedInject constructor( val isInCaptureMode: Boolean get() { - return mActivity is CaptureActivity + return entryPoint.isCaptureSession } fun updateLastCapturedItem(item: CapturedItem) { @@ -1053,7 +1048,7 @@ class CamConfig @AssistedInject constructor( val bindRequest = CameraBindRequest( includesVideoCapture = !isQRMode && isVideoMode, - includesImageCapture = !isQRMode && !mActivity.requiresVideoModeOnly, + includesImageCapture = !isQRMode && !entryPoint.requiresVideoModeOnly, aspectRatio = aspectRatio, imageCaptureTargetRotation = imageCapture?.targetRotation ?: rotation, previewTargetRotation = preview?.targetRotation ?: rotation, @@ -1274,7 +1269,7 @@ class CamConfig @AssistedInject constructor( // transiently-failed probe always was, and comes back on the refresh that follows its probe. private fun availableModes(): Set { return resolveAvailableModes( - allowsQrScanning = mActivity !is SecureMainActivity, + allowsQrScanning = entryPoint.allowsQrScanning, extensionsAvailable = extensionsManager != null && cameraProvider != null, ) } @@ -1295,7 +1290,7 @@ class CamConfig @AssistedInject constructor( private var extensionProbesInFlight = false private fun loadTabs() { - if (!mActivity.shouldShowCameraModeTabs()) { + if (!entryPoint.showsCameraModeTabs) { return } @@ -1378,7 +1373,7 @@ class CamConfig @AssistedInject constructor( // 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()) { + if (entryPoint.showsCameraModeTabs) { mActivity.goToModeTab(currentMode) } } 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/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/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) + } +} From d8047c1c039c035298c387ff9c926f16fd161b8b Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 21:45:48 +0200 Subject: [PATCH 11/23] Remove MainActivity from CamConfig --- app/config/ktlint/baseline.xml | 262 ++++++------- app/detekt-baseline-debug.xml | 1 + .../java/app/grapheneos/camera/CamConfig.kt | 135 +++---- .../grapheneos/camera/CameraSessionEffects.kt | 78 ++++ .../camera/CameraSessionEnvironment.kt | 28 ++ .../ui/activities/CameraSessionHandler.kt | 347 ++++++++++++++++++ .../camera/ui/activities/MainActivity.kt | 249 +------------ 7 files changed, 674 insertions(+), 426 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt create mode 100644 app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index c0d9e63d0..f51e057a2 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,25 +114,25 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -538,6 +538,12 @@ + + + + + + @@ -566,125 +572,121 @@ - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 7a5494548..d8f95ba8f 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -14,6 +14,7 @@ EmptyCatchBlock:QRAnalyzer.kt:QRAnalyzer${ } EmptyFunctionBlock:ActivityLifeCycleHelper.kt:ActivityLifeCycleHelper${} EmptyFunctionBlock:App.kt:App.<no name provided>${} + EmptyFunctionBlock:CameraSessionHandler.kt:CameraSessionHandler.<no name provided>${} EmptyFunctionBlock:ImageCapturer.kt:ImageCapturer.<no name provided>${} EmptyFunctionBlock:MainActivity.kt:MainActivity${} EmptyFunctionBlock:MainActivity.kt:MainActivity.<no name provided>${} diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 644960a11..9c432dcce 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -29,7 +29,6 @@ import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.Quality import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture -import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import app.grapheneos.camera.analyzer.QRAnalyzer @@ -44,8 +43,6 @@ 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.showQrFormatsDialog -import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog import app.grapheneos.camera.ui.videoQualityTitle import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper @@ -58,7 +55,6 @@ import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport -import app.grapheneos.camera.ui.activities.MainActivity import com.google.zxing.BarcodeFormat import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -77,7 +73,8 @@ import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") class CamConfig @AssistedInject constructor( - @Assisted private val mActivity: MainActivity, + @Assisted private val environment: CameraSessionEnvironment, + @Assisted private val effects: CameraSessionEffects, private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, @@ -93,7 +90,11 @@ class CamConfig @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(activity: MainActivity): CamConfig + + fun create( + environment: CameraSessionEnvironment, + effects: CameraSessionEffects, + ): CamConfig } companion object { @@ -172,17 +173,17 @@ class CamConfig @AssistedInject constructor( private val zoomStateObserver = Observer { zoomState = it if (it.linearZoom != 0f || it.zoomRatio != 1f) { - mActivity.updateZoomThumb() + effects.updateZoomThumb() } } private val handler = Handler(Looper.getMainLooper()) private val attachZoomState = Runnable { - if (!mActivity.isSessionActive) return@Runnable + if (!environment.isSessionActive) return@Runnable zoomStateSource = camera?.cameraInfo?.zoomState?.also { - it.observe(mActivity, zoomStateObserver) + it.observe(environment.sessionLifecycleOwner, zoomStateObserver) } zoomState = zoomStateSource?.value @@ -209,7 +210,7 @@ class CamConfig @AssistedInject constructor( var iAnalyzer: ImageAnalysis? = null @set:VisibleForTesting - var mPlayer = TunePlayer(mActivity) + var mPlayer = environment.createTunePlayer() private var settings: CameraSettings = runBlocking { settingsRepository.settings.first() } @@ -368,7 +369,7 @@ class CamConfig @AssistedInject constructor( private fun applyFlashMode(value: Int) { flashMode = value imageCapture?.flashMode = value - mActivity.onFlashModeChanged() + effects.onFlashModeChanged() } var focusTimeout: Long by setting( @@ -396,7 +397,7 @@ class CamConfig @AssistedInject constructor( } if (isQRMode) { - mActivity.applyScanAllCodesChrome(value) + effects.applyScanAllCodesChrome(value) } qrAnalyzer?.refreshHints() @@ -411,7 +412,7 @@ class CamConfig @AssistedInject constructor( settingsRepository.update { it.copy(includeAudio = value) } } - mActivity.onIncludeAudioChanged(value) + effects.onIncludeAudioChanged(value) } var enableEIS: Boolean @@ -423,7 +424,7 @@ class CamConfig @AssistedInject constructor( settingsRepository.update { it.copy(enableEis = value) } } - mActivity.onEnableEisChanged(value) + effects.onEnableEisChanged(value) } var enableZsl: Boolean by setting( @@ -531,7 +532,7 @@ class CamConfig @AssistedInject constructor( // the preference back here would resurrect the very stale "on" the coercion exists to drop. var requireLocation: Boolean = false set(value) { - mActivity.locationCamConfigChanged(value) + effects.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. @@ -539,7 +540,7 @@ class CamConfig @AssistedInject constructor( settingsRepository.setGeoTagging(value) }?.let { modeSettings = it } - mActivity.onGeoTaggingChanged(value) + effects.onGeoTaggingChanged(value) field = value } @@ -554,7 +555,7 @@ class CamConfig @AssistedInject constructor( settingsRepository.setSelfIllumination(value) }?.let { modeSettings = it } - mActivity.onSelfIlluminationChanged(value) + effects.onSelfIlluminationChanged(value) } fun setQRScanningFor(format: String, selected: Boolean) { @@ -571,7 +572,7 @@ class CamConfig @AssistedInject constructor( } } else { if (allowedFormats.size == 1) { - mActivity.showMessage(R.string.no_barcode_selected) + effects.showMessage(R.string.no_barcode_selected) } else { allowedFormats.remove(BarcodeFormat.valueOf(format)) } @@ -593,7 +594,7 @@ class CamConfig @AssistedInject constructor( slotCurrentMode() if (isVideoMode) { - mActivity.reloadVideoQualities() + effects.reloadVideoQualities() } applyFlashMode(modeSettings.flashMode) @@ -603,17 +604,17 @@ class CamConfig @AssistedInject constructor( // 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() + !environment.shouldAskForLocationPermission() selfIlluminate = modeSettings.selfIllumination - mActivity.showOnlyRelevantSettings() + effects.showOnlyRelevantSettings() } fun loadSettings() { - mActivity.onGridTypeChanged() + effects.onGridTypeChanged() - mActivity.onFocusTimeoutChanged(focusTimeoutLabel(settings.focusTimeoutSeconds)) + effects.onFocusTimeoutChanged(focusTimeoutLabel(settings.focusTimeoutSeconds)) includeAudio = settings.includeAudio @@ -624,7 +625,7 @@ class CamConfig @AssistedInject constructor( BarcodeFormat.entries.filter { it.name in settings.enabledBarcodeFormats }, ) - mActivity.selectBarcodeFormatToggles(allowedFormats) + effects.selectBarcodeFormatToggles(allowedFormats) qrAnalyzer?.refreshHints() } @@ -655,7 +656,7 @@ class CamConfig @AssistedInject constructor( setFlashMode(next) } else { - mActivity.showMessage(R.string.flash_unavailable_in_selected_mode) + effects.showMessage(R.string.flash_unavailable_in_selected_mode) } } @@ -690,10 +691,10 @@ class CamConfig @AssistedInject constructor( // Else revert back to the old facing (while displaying an error message // to the user) lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - mActivity.showMessage(R.string.rear_camera_unavailable) + effects.showMessage(R.string.rear_camera_unavailable) CameraSelector.LENS_FACING_FRONT } else { - mActivity.showMessage(R.string.front_camera_unavailable) + effects.showMessage(R.string.front_camera_unavailable) CameraSelector.LENS_FACING_BACK } } @@ -706,9 +707,9 @@ class CamConfig @AssistedInject constructor( return } - cameraProviderSource.acquireProvider(mActivity) { provider -> + cameraProviderSource.acquireProvider(environment.sessionContext) { provider -> when (provider) { - null -> mActivity.showMessage(R.string.camera_provider_init_failure) + null -> effects.showMessage(R.string.camera_provider_init_failure) else -> onCameraProviderReady(provider, forced) } @@ -736,9 +737,12 @@ class CamConfig @AssistedInject constructor( } } - cameraProviderSource.acquireExtensionsManager(mActivity, provider) { manager -> + cameraProviderSource.acquireExtensionsManager( + environment.sessionContext, + provider, + ) { manager -> if (manager == null) { - mActivity.showMessage(R.string.extensions_manager_init_failure) + effects.showMessage(R.string.extensions_manager_init_failure) } else { extensionsManager = manager } @@ -917,10 +921,10 @@ class CamConfig @AssistedInject constructor( selected = selected, ) ?: return - mActivity.showMessage( - mActivity.getString( + effects.showMessage( + environment.sessionContext.getString( R.string.quality_unsupported, - videoQualityTitle(mActivity, droppedQuality), + videoQualityTitle(environment.sessionContext, droppedQuality), ) ) } @@ -955,18 +959,18 @@ class CamConfig @AssistedInject constructor( if ((!forced && camera != null) || cameraProvider == null) return // Cancel any pending capture requests - mActivity.cancelPendingCapture() + effects.cancelPendingCapture() - mActivity.hideExposurePanel() + effects.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 = mActivity.displayRotation + val rotation = environment.displayRotation - if (!mActivity.isSessionActive) return + 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 @@ -988,7 +992,7 @@ class CamConfig @AssistedInject constructor( // To use the last frame instead of showing a blank screen when // the camera that is being currently used gets unbind - mActivity.updateLastFrame() + effects.updateLastFrame() // Unbind/close all other camera(s) [if any] cameraProvider?.unbindAll() @@ -1065,7 +1069,7 @@ class CamConfig @AssistedInject constructor( val plan = buildCameraSessionPlan(bindRequest) if (isQRMode) { - val analyzer = QRAnalyzer(mActivity) + val analyzer = environment.createQrAnalyzer() val strategy = ResolutionStrategy( Size(960, 960), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER @@ -1077,7 +1081,7 @@ class CamConfig @AssistedInject constructor( .setOutputImageRotationEnabled(true) .build() qrAnalyzer = analyzer - mActivity.startFocusTimer() + effects.startFocusTimer() iAnalyzer = mIAnalyzer mIAnalyzer.setAnalyzer(cameraExecutor, analyzer) cameraSelector = CameraSelector.Builder() @@ -1085,7 +1089,7 @@ class CamConfig @AssistedInject constructor( if (isLensFacingSupported(CameraSelector.LENS_FACING_BACK)) { CameraSelector.LENS_FACING_BACK } else { - mActivity.showMessage(R.string.qr_rear_camera_unavailable) + effects.showMessage(R.string.qr_rear_camera_unavailable) CameraSelector.LENS_FACING_FRONT } ) @@ -1094,7 +1098,7 @@ class CamConfig @AssistedInject constructor( } else { if (isVideoMode) { - mActivity.setMicMutedIconVisible(!includeAudio) + effects.setMicMutedIconVisible(!includeAudio) } plan.videoCapture?.let { @@ -1110,10 +1114,10 @@ class CamConfig @AssistedInject constructor( preview = plan.preview.also { useCasesList.add(it) - it.surfaceProvider = mActivity.previewSurfaceProvider + it.surfaceProvider = environment.previewSurfaceProvider } - mActivity.forceUpdateOrientationSensor() + effects.forceUpdateOrientationSensor() // 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 @@ -1143,7 +1147,7 @@ class CamConfig @AssistedInject constructor( cameraProvider?.getCameraInfo(cameraSelector) } catch (exception: IllegalArgumentException) { Log.e(TAG, "Failed to query camera info", exception) - mActivity.showMessage(R.string.bind_failure) + effects.showMessage(R.string.bind_failure) return } @@ -1183,14 +1187,14 @@ class CamConfig @AssistedInject constructor( val requested = plan.preferredFeatures.toList() val boundLensFacing = lensFacing sessionConfig.setFeatureSelectionListener( - ContextCompat.getMainExecutor(mActivity) + environment.sessionMainExecutor ) { selected -> onFeaturesSelected(boundLensFacing, requested, requiredQualityFeature, selected) } } camera = cameraProvider!!.bindToLifecycle( - mActivity, cameraSelector, + environment.sessionLifecycleOwner, cameraSelector, sessionConfig ) } catch (exception: RuntimeException) { @@ -1205,7 +1209,7 @@ class CamConfig @AssistedInject constructor( // stay visible. if (exception is IllegalArgumentException) { Log.e(TAG, "Failed to bind use cases", exception) - mActivity.showMessage(R.string.bind_failure) + effects.showMessage(R.string.bind_failure) return } throw exception @@ -1224,7 +1228,7 @@ class CamConfig @AssistedInject constructor( Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) extensionAvailabilityStore.record(key, usable = false) - mActivity.showMessage(R.string.extension_mode_unavailable) + 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 @@ -1249,19 +1253,19 @@ class CamConfig @AssistedInject constructor( handler.removeCallbacks(attachZoomState) handler.post(attachZoomState) - mActivity.updateZoomThumb(false) + effects.updateZoomThumb(false) - camera?.cameraInfo?.exposureState?.let { mActivity.applyExposureState(it) } + camera?.cameraInfo?.exposureState?.let { effects.applyExposureState(it) } - mActivity.resetTorchToggle() + effects.resetTorchToggle() - camera?.cameraInfo?.let { mActivity.onPreviewBound(aspectRatio, it) } + camera?.cameraInfo?.let { effects.onPreviewBound(aspectRatio, it) } - mActivity.updateGyroscopeIndicator(isInPhotoMode) + effects.updateGyroscopeIndicator(isInPhotoMode) } fun snapPreview() { - mActivity.flashPreview(selfIlluminate) + effects.flashPreview(selfIlluminate) } // probeOnMiss is false because tab refreshes must never pay for a vendor probe on the main @@ -1322,9 +1326,9 @@ class CamConfig @AssistedInject constructor( ) } - ContextCompat.getMainExecutor(mActivity).execute { + environment.sessionMainExecutor.execute { extensionProbesInFlight = false - if (!mActivity.isSessionActive) return@execute + if (!environment.isSessionActive) return@execute if (probedCameraProvider !== provider) { // The camera stack was reinitialized while probing: these verdicts describe @@ -1342,7 +1346,7 @@ class CamConfig @AssistedInject constructor( } private fun buildTabs() { - mActivity.setCameraModeTabs( + effects.setCameraModeTabs( modes = availableModes(), currentMode = currentMode, ) @@ -1355,13 +1359,13 @@ class CamConfig @AssistedInject constructor( currentMode = mode - mActivity.cancelFocusTimer() + effects.cancelFocusTimer() isQRMode = mode == CameraMode.QR_SCAN isVideoMode = mode == CameraMode.VIDEO - mActivity.applyModeChrome( + effects.applyModeChrome( mode = mode, isVideoMode = isVideoMode, scanAllCodes = scanAllCodes, @@ -1374,7 +1378,7 @@ class CamConfig @AssistedInject constructor( // 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) { - mActivity.goToModeTab(currentMode) + effects.goToModeTab(currentMode) } } @@ -1383,8 +1387,7 @@ class CamConfig @AssistedInject constructor( .filterNot { it in commonFormats } .map { it.name } - showQrFormatsDialog( - activity = mActivity, + effects.showBarcodeFormatPicker( optionNames = optionNames, initialValues = optionNames.map { it in settings.enabledBarcodeFormats }, onConfirm = { values -> applyBarcodeFormats(optionNames, values) }, @@ -1398,7 +1401,7 @@ class CamConfig @AssistedInject constructor( val allCommonFormatsDisabled = commonFormats.none { allowedFormats.contains(it) } if (allCommonFormatsDisabled && values.none { it }) { - mActivity.showMessage(R.string.no_barcode_selected) + effects.showMessage(R.string.no_barcode_selected) return } @@ -1432,6 +1435,6 @@ class CamConfig @AssistedInject constructor( // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH storageLocation = CapturedItemRepository.MEDIA_STORE_LOCATION - showStorageLocationNotFoundDialog(mActivity) + effects.showStorageLocationNotFound() } } diff --git a/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt b/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt new file mode 100644 index 000000000..19243b2d3 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt @@ -0,0 +1,78 @@ +package app.grapheneos.camera + +import androidx.annotation.StringRes +import androidx.camera.core.CameraInfo +import androidx.camera.core.ExposureState +import app.grapheneos.camera.data.core.model.CameraMode +import com.google.zxing.BarcodeFormat + +interface CameraSessionEffects { + + fun showMessage(@StringRes message: Int) + + fun showMessage(message: String) + + 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 updateLastFrame() + + fun flashPreview(selfIlluminate: Boolean) + + fun updateZoomThumb(shouldShowPanel: Boolean = true) + + fun applyExposureState(exposureState: ExposureState) + + fun hideExposurePanel() + + fun setMicMutedIconVisible(visible: Boolean) + + fun updateGyroscopeIndicator(inPhotoMode: Boolean) + + fun forceUpdateOrientationSensor() + + fun startFocusTimer() + + fun cancelFocusTimer() + + fun cancelPendingCapture() + + fun selectBarcodeFormatToggles(formats: List) + + fun onFlashModeChanged() + + fun onGridTypeChanged() + + fun onFocusTimeoutChanged(label: String) + + fun onIncludeAudioChanged(enabled: Boolean) + + fun onEnableEisChanged(enabled: Boolean) + + fun onGeoTaggingChanged(enabled: Boolean) + + fun onSelfIlluminationChanged(enabled: Boolean) + + fun locationCamConfigChanged(required: Boolean) + + fun reloadVideoQualities() + + fun showOnlyRelevantSettings() + + fun resetTorchToggle() + + fun showBarcodeFormatPicker( + optionNames: List, + initialValues: List, + onConfirm: (List) -> Unit, + ) + + fun showStorageLocationNotFound() +} diff --git a/app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt b/app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt new file mode 100644 index 000000000..fae25671a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt @@ -0,0 +1,28 @@ +package app.grapheneos.camera + +import android.content.Context +import androidx.camera.core.Preview +import androidx.lifecycle.LifecycleOwner +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/ui/activities/CameraSessionHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt new file mode 100644 index 000000000..8cbe376d4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt @@ -0,0 +1,347 @@ +package app.grapheneos.camera.ui.activities + +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.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import app.grapheneos.camera.App +import app.grapheneos.camera.CameraSessionEffects +import app.grapheneos.camera.CameraSessionEnvironment +import app.grapheneos.camera.R +import app.grapheneos.camera.TunePlayer +import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.core.model.CameraMode +import app.grapheneos.camera.ktx.applyPreviewRatio +import app.grapheneos.camera.ui.showQrFormatsDialog +import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog +import com.google.zxing.BarcodeFormat +import java.util.concurrent.Executor + +internal class CameraSessionHandler( + private val activity: MainActivity, +) : CameraSessionEnvironment, CameraSessionEffects { + + override fun showMessage(@StringRes message: Int) { + activity.showMessage(message) + } + + override fun showMessage(message: String) { + activity.showMessage(message) + } + + override fun updateLastFrame() { + activity.updateLastFrame() + } + + override fun forceUpdateOrientationSensor() { + activity.forceUpdateOrientationSensor() + } + + override fun startFocusTimer() { + activity.startFocusTimer() + } + + override fun cancelFocusTimer() { + activity.cancelFocusTimer() + } + + override fun locationCamConfigChanged(required: Boolean) { + activity.locationCamConfigChanged(required) + } + + override val sessionContext: Context + get() { + return activity + } + + override val sessionLifecycleOwner: LifecycleOwner + get() { + return activity + } + + override val sessionMainExecutor: Executor + get() { + return ContextCompat.getMainExecutor(activity) + } + + 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 showBarcodeFormatPicker( + optionNames: List, + initialValues: List, + onConfirm: (List) -> Unit, + ) { + showQrFormatsDialog( + activity = activity, + optionNames = optionNames, + initialValues = initialValues, + onConfirm = onConfirm, + ) + } + + override fun showStorageLocationNotFound() { + showStorageLocationNotFoundDialog(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 ?: @Suppress("DEPRECATION") + activity.windowManager.defaultDisplay.rotation + } + + // 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 -> @Suppress("DEPRECATION") activity.windowManager.defaultDisplay.rotation + } + } + + 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) + 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 onGridTypeChanged() { + activity.settingsDialog.updateGridToggleUI() + } + + override fun onFocusTimeoutChanged(label: String) { + activity.settingsDialog.updateFocusTimeout(label) + } + + override fun onIncludeAudioChanged(enabled: Boolean) { + activity.settingsDialog.includeAudioToggle.isChecked = enabled + } + + override fun onEnableEisChanged(enabled: Boolean) { + activity.settingsDialog.enableEISToggle.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 selectBarcodeFormatToggles(formats: List) { + val toggles = mapOf( + BarcodeFormat.QR_CODE to activity.qrToggle, + BarcodeFormat.AZTEC to activity.azToggle, + BarcodeFormat.PDF_417 to activity.cBToggle, + BarcodeFormat.DATA_MATRIX to activity.dmToggle, + ) + + formats.forEach { format -> + toggles[format]?.isSelected = true + } + } + + 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) + } + + 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/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 5314a71d7..80e15388c 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 @@ -38,7 +38,6 @@ import android.view.View import android.view.View.OnTouchListener import android.view.ViewGroup import android.view.ViewTreeObserver -import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.LinearInterpolator import android.view.animation.RotateAnimation @@ -56,8 +55,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.camera.core.CameraInfo -import androidx.camera.core.ExposureState import androidx.camera.core.FocusMeteringAction import androidx.camera.core.MeteringPointFactory import androidx.camera.core.Preview @@ -780,227 +777,6 @@ open class MainActivity : AppCompatActivity(), } - val isSessionActive: Boolean - get() { - return !isDestroyed && !isFinishing - } - - val previewSurfaceProvider: Preview.SurfaceProvider - get() { - return previewView.surfaceProvider - } - - val displayRotation: Int - get() { - return when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - display?.rotation ?: @Suppress("DEPRECATION") - windowManager.defaultDisplay.rotation - } - - // 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 -> @Suppress("DEPRECATION") windowManager.defaultDisplay.rotation - } - } - - fun cancelPendingCapture() { - imageCapturer.cancelPendingCaptureRequest() - } - - fun hideExposurePanel() { - exposureBar.hidePanel() - } - - fun applyExposureState(exposureState: ExposureState) { - exposureBar.setExposureConfig(exposureState) - } - - fun updateZoomThumb(shouldShowPanel: Boolean = true) { - zoomBar.updateThumb(shouldShowPanel) - } - - fun setMicMutedIconVisible(visible: Boolean) { - micOffIcon.visibility = when { - visible -> View.VISIBLE - else -> View.GONE - } - } - - fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) { - // Focus camera on touch/tap - previewView.setOnTouchListener(this) - previewView.applyPreviewRatio(aspectRatio, cameraInfo) - } - - fun updateGyroscopeIndicator(inPhotoMode: Boolean) { - if (inPhotoMode) { - sensorNotifier?.forceUpdateGyro() - } else { - gCircleFrame.visibility = View.GONE - } - } - - fun setCameraModeTabs(modes: Set, currentMode: CameraMode) { - tabLayout.setModes( - modes = modes, - currentMode = currentMode, - onTabTouched = ::finalizeMode, - ) - } - - fun goToModeTab(mode: CameraMode) { - tabLayout.getTabForMode(mode)?.let { tab -> - tabLayout.goToTab(tab) - } - } - - fun onFlashModeChanged() { - settingsDialog.updateFlashMode() - } - - fun onGridTypeChanged() { - settingsDialog.updateGridToggleUI() - } - - fun onFocusTimeoutChanged(label: String) { - settingsDialog.updateFocusTimeout(label) - } - - fun onIncludeAudioChanged(enabled: Boolean) { - settingsDialog.includeAudioToggle.isChecked = enabled - } - - fun onEnableEisChanged(enabled: Boolean) { - settingsDialog.enableEISToggle.isChecked = enabled - } - - fun onGeoTaggingChanged(enabled: Boolean) { - settingsDialog.locToggle.isChecked = enabled - } - - fun onSelfIlluminationChanged(enabled: Boolean) { - settingsDialog.selfIlluminationToggle.isChecked = enabled - settingsDialog.selfIllumination() - } - - fun reloadVideoQualities() { - settingsDialog.reloadQualities() - } - - fun showOnlyRelevantSettings() { - settingsDialog.showOnlyRelevantSettings() - } - - fun resetTorchToggle() { - settingsDialog.torchToggle.isChecked = false - } - - fun selectBarcodeFormatToggles(formats: List) { - val toggles = mapOf( - BarcodeFormat.QR_CODE to qrToggle, - BarcodeFormat.AZTEC to azToggle, - BarcodeFormat.PDF_417 to cBToggle, - BarcodeFormat.DATA_MATRIX to dmToggle, - ) - - formats.forEach { format -> - toggles[format]?.isSelected = true - } - } - - fun applyModeChrome( - mode: CameraMode, - isVideoMode: Boolean, - scanAllCodes: Boolean, - ) { - when (mode) { - CameraMode.QR_SCAN -> { - qrOverlay.visibility = View.VISIBLE - thirdOption.visibility = View.INVISIBLE - - applyScanAllCodesChrome(scanAllCodes) - - cancelButtonView.visibility = View.INVISIBLE - - captureButton.setBackgroundResource(android.R.color.transparent) - // Entering QR mode always leaves the torch off - setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) - - micOffIcon.visibility = View.GONE - } - - else -> { - qrOverlay.visibility = View.INVISIBLE - thirdOption.visibility = View.VISIBLE - setFlipCameraIcon(R.drawable.flip_camera, R.string.flip_camera) - cancelButtonView.visibility = View.VISIBLE - - qrScanToggles.visibility = View.GONE - - captureButton.setBackgroundResource(R.drawable.cbutton_bg) - - if (isVideoMode) { - setCaptureButtonIcon(R.drawable.recording, R.string.start_recording) - } else { - setCaptureButtonIcon(R.drawable.camera_shutter, R.string.capture) - micOffIcon.visibility = View.GONE - } - } - } - - updateSelfTimerBadge() - } - - fun applyScanAllCodesChrome(scanAllCodes: Boolean) { - if (scanAllCodes) { - setFlipCameraIcon(R.drawable.cancel, R.string.stop_scanning_all_formats) - qrScanToggles.visibility = View.GONE - } else { - setFlipCameraIcon(R.drawable.auto, R.string.scan_all_formats) - qrScanToggles.visibility = View.VISIBLE - } - } - - fun flashPreview(selfIlluminate: Boolean) { - val animation: Animation = when { - selfIlluminate -> AlphaAnimation(0f, 0.8f) - else -> AlphaAnimation(1f, 0f) - } - - animation.interpolator = LinearInterpolator() - - if (selfIlluminate) { - animation.duration = PREVIEW_SL_OVERLAY_DUR - animation.fillAfter = true - mainOverlay.setImageResource(android.R.color.white) - } else { - animation.duration = PREVIEW_SNAP_DURATION - animation.repeatMode = Animation.REVERSE - mainOverlay.setImageResource(android.R.color.black) - } - - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationStart(p0: Animation?) { - mainOverlay.visibility = View.VISIBLE - } - - override fun onAnimationEnd(p0: Animation?) { - if (!selfIlluminate) { - mainOverlay.visibility = View.INVISIBLE - mainOverlay.setImageResource(android.R.color.transparent) - } - } - - override fun onAnimationRepeat(p0: Animation?) {} - } - ) - - mainOverlay.startAnimation(animation) - } - lateinit var gestureDetector: GestureDetector @SuppressLint("ClickableViewAccessibility") @@ -1011,7 +787,11 @@ open class MainActivity : AppCompatActivity(), gestureDetector = GestureDetector(this, this) - camConfig = camConfigFactory.create(this) + val sessionHandler = CameraSessionHandler(this) + camConfig = camConfigFactory.create( + environment = sessionHandler, + effects = sessionHandler, + ) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay imageCapturer = ImageCapturer(this) @@ -1752,9 +1532,6 @@ open class MainActivity : AppCompatActivity(), private lateinit var cameraControl: CameraControl companion object { - private const val PREVIEW_SNAP_DURATION = 200L - private const val PREVIEW_SL_OVERLAY_DUR = 200L - private const val TAG = "GOCam" private const val autoCenterFocusDuration = 2000L private val hexArray = "0123456789ABCDEF".toCharArray() @@ -1896,7 +1673,15 @@ open class MainActivity : AppCompatActivity(), return false } - fun showMessage(@StringRes msg: Int, action: String? = null, callback: View.OnClickListener? = null) { + fun showMessage(@StringRes message: Int) { + showMessage(getString(message)) + } + + fun showMessage( + @StringRes msg: Int, + action: String?, + callback: View.OnClickListener?, + ) { showMessage(getString(msg), action, callback) } @@ -1949,7 +1734,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) From db42a797b09d3bec9e0cc4510cd4a912efd73e8c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 21:56:59 +0200 Subject: [PATCH 12/23] Move settings dialog setup into dialog --- app/config/ktlint/baseline.xml | 288 +++++++++--------- .../java/app/grapheneos/camera/CamConfig.kt | 22 +- .../grapheneos/camera/CameraSessionEffects.kt | 6 - .../grapheneos/camera/ui/SettingsDialog.kt | 7 + .../ui/activities/CameraSessionHandler.kt | 12 - .../camera/ui/activities/MainActivity.kt | 1 + 6 files changed, 156 insertions(+), 180 deletions(-) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index f51e057a2..dade48e99 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,25 +114,25 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -418,78 +418,78 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -625,68 +625,68 @@ - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 9c432dcce..573aba15c 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -42,7 +42,6 @@ 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.domain.camera.mapper.VideoQualityFeatureMapper @@ -415,17 +414,10 @@ class CamConfig @AssistedInject constructor( effects.onIncludeAudioChanged(value) } - var enableEIS: Boolean - get() { - return settings.enableEis - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(enableEis = value) } - } - - effects.onEnableEisChanged(value) - } + var enableEIS: Boolean by setting( + read = { it.enableEis }, + write = { current, value -> current.copy(enableEis = value) }, + ) var enableZsl: Boolean by setting( read = { it.enableZsl }, @@ -612,14 +604,8 @@ class CamConfig @AssistedInject constructor( } fun loadSettings() { - effects.onGridTypeChanged() - - effects.onFocusTimeoutChanged(focusTimeoutLabel(settings.focusTimeoutSeconds)) - includeAudio = settings.includeAudio - enableEIS = settings.enableEis - allowedFormats.clear() allowedFormats.addAll( BarcodeFormat.entries.filter { it.name in settings.enabledBarcodeFormats }, diff --git a/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt b/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt index 19243b2d3..a06db6f88 100644 --- a/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt +++ b/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt @@ -48,14 +48,8 @@ interface CameraSessionEffects { fun onFlashModeChanged() - fun onGridTypeChanged() - - fun onFocusTimeoutChanged(label: String) - fun onIncludeAudioChanged(enabled: Boolean) - fun onEnableEisChanged(enabled: Boolean) - fun onGeoTaggingChanged(enabled: Boolean) fun onSelfIlluminationChanged(enabled: Boolean) 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..d2fe667ba 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 @@ -762,6 +763,12 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } + fun loadInitialState() { + updateGridToggleUI() + updateFocusTimeout(focusTimeoutLabel(camConfig.focusTimeout)) + enableEISToggle.isChecked = camConfig.enableEIS + } + fun updateGridToggleUI() { mActivity.previewGrid.postInvalidate() // The description has to travel with the drawable: this control cycles through four diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt index 8cbe376d4..9a78fa01f 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt @@ -180,22 +180,10 @@ internal class CameraSessionHandler( activity.settingsDialog.updateFlashMode() } - override fun onGridTypeChanged() { - activity.settingsDialog.updateGridToggleUI() - } - - override fun onFocusTimeoutChanged(label: String) { - activity.settingsDialog.updateFocusTimeout(label) - } - override fun onIncludeAudioChanged(enabled: Boolean) { activity.settingsDialog.includeAudioToggle.isChecked = enabled } - override fun onEnableEisChanged(enabled: Boolean) { - activity.settingsDialog.enableEISToggle.isChecked = enabled - } - override fun onGeoTaggingChanged(enabled: Boolean) { activity.settingsDialog.locToggle.isChecked = enabled } 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 80e15388c..03628dc1b 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 @@ -1123,6 +1123,7 @@ open class MainActivity : AppCompatActivity(), azToggle.key = BarcodeFormat.AZTEC.name camConfig.loadSettings() + settingsDialog.loadInitialState() gCircle = binding.gCircle gAngleTextView = binding.gCircleText From a7443d245cca5ccfe45cbebb91c8fd712b8bcafa Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sat, 5 Sep 2026 22:46:05 +0200 Subject: [PATCH 13/23] Move camera session out of CamConfig --- app/config/ktlint/baseline.xml | 263 +++--- app/detekt-baseline-debug.xml | 32 +- .../java/app/grapheneos/camera/CamConfig.kt | 812 +++--------------- .../data/camera/model/CameraBindSettings.kt | 22 + .../data/camera/repository/CameraSession.kt | 804 +++++++++++++++++ .../repository}/CameraSessionEnvironment.kt | 3 +- .../camera/ui/activities/MainActivity.kt | 3 +- .../ViewfinderEffectHandler.kt} | 90 +- .../viewfinder/ViewfinderEffects.kt} | 4 +- 9 files changed, 1162 insertions(+), 871 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindSettings.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt rename app/src/main/java/app/grapheneos/camera/{ => data/camera/repository}/CameraSessionEnvironment.kt (86%) rename app/src/main/java/app/grapheneos/camera/ui/{activities/CameraSessionHandler.kt => viewfinder/ViewfinderEffectHandler.kt} (97%) rename app/src/main/java/app/grapheneos/camera/{CameraSessionEffects.kt => ui/viewfinder/ViewfinderEffects.kt} (95%) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index dade48e99..a5b592274 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -114,25 +114,19 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -343,6 +337,24 @@ + + + + + + + + + + + + + + + + + + @@ -538,12 +550,6 @@ - - - - - - @@ -572,13 +578,12 @@ - - - - - - - + + + + + + @@ -586,107 +591,108 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + @@ -770,6 +776,13 @@ + + + + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index d8f95ba8f..ef309c539 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -4,7 +4,7 @@ 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:CameraSession$@SuppressLint("RestrictedApi") 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 @@ -14,12 +14,12 @@ EmptyCatchBlock:QRAnalyzer.kt:QRAnalyzer${ } EmptyFunctionBlock:ActivityLifeCycleHelper.kt:ActivityLifeCycleHelper${} EmptyFunctionBlock:App.kt:App.<no name provided>${} - EmptyFunctionBlock:CameraSessionHandler.kt:CameraSessionHandler.<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:ZoomableImageView.kt:ZoomableImageView.<no name provided>${} HasPlatformType:ImageSaver.kt:ImageSaver$val contentResolver = appContext.contentResolver HasPlatformType:ImageSaver.kt:ImageSaver$val mainThreadExecutor = appContext.mainExecutor @@ -29,13 +29,12 @@ 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 + InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is IllegalArgumentException + InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is UnsupportedOperationException + InstanceOfCheckForException:CameraSession.kt:CameraSession$exception is IllegalArgumentException LargeClass:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener LongMethod:BlurBitmap.kt:BlurBitmap$operator fun get: Bitmap - LongMethod:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera + LongMethod:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome LongMethod:GallerySliderAdapter.kt:GallerySliderAdapter$override fun onBindViewHolder LongMethod:InAppGallery.kt:InAppGallery$override fun onCreate LongMethod:InAppGallery.kt:InAppGallery$private fun showCurrentMediaDetails @@ -127,10 +126,11 @@ ReturnCount:App.kt:App$fun getLocation: Location? ReturnCount:App.kt:App$fun isAnyLocationProvideActive: Boolean ReturnCount:BottomTabLayout.kt:BottomTabLayout$private fun settleDuration: Long - ReturnCount:CamConfig.kt:CamConfig$@SuppressLint("RestrictedApi") fun startCamera - 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$fun startCamera + ReturnCount:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome + ReturnCount:CameraSession.kt:CameraSession$fun isLensFacingSupported: Boolean + ReturnCount:CameraSession.kt:CameraSession$fun probeUnknownExtensions + ReturnCount:CameraSession.kt:CameraSession$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 @@ -143,7 +143,7 @@ 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: IllegalArgumentException + SwallowedException:CameraSession.kt:CameraSession$e: IllegalArgumentException SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception SwallowedException:CapturedItems.kt:e: ActivityNotFoundException SwallowedException:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception @@ -153,8 +153,8 @@ 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:CameraSession$e: Exception + TooGenericExceptionCaught:CameraSession.kt:CameraSession$exception: RuntimeException TooGenericExceptionCaught:CaptureActivity.kt:CaptureActivity$e: Exception TooGenericExceptionCaught:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception TooGenericExceptionCaught:ImageSaver.kt:ImageSaver$deleteException: Exception @@ -172,8 +172,8 @@ 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:CameraSession.kt:CameraSession$camera!! + UnsafeCallOnNullableType:CameraSession.kt:CameraSession$cameraProvider!! UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 573aba15c..c65384642 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -2,40 +2,24 @@ package app.grapheneos.camera import android.annotation.SuppressLint import android.net.Uri -import android.os.Handler -import android.os.Looper import android.provider.MediaStore import android.util.Log -import android.util.Size import androidx.annotation.VisibleForTesting 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.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.model.ExtensionKey -import app.grapheneos.camera.data.camera.repository.CameraProviderSource -import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport -import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore +import app.grapheneos.camera.data.camera.model.CameraBindSettings +import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment +import app.grapheneos.camera.data.camera.repository.CameraSession import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.model.CameraSettings @@ -43,24 +27,16 @@ 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.ui.videoQualityTitle +import app.grapheneos.camera.ui.viewfinder.ViewfinderEffects import app.grapheneos.camera.data.settings.repository.SettingsRepository -import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper -import app.grapheneos.camera.domain.camera.model.CameraBindRequest import app.grapheneos.camera.domain.camera.model.CameraEntryPoint -import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest -import app.grapheneos.camera.domain.camera.model.ImageCaptureMode -import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport -import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality -import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport import com.google.zxing.BarcodeFormat import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import java.io.IOException -import java.util.concurrent.Executors -import kotlin.concurrent.thread import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope @@ -73,18 +49,13 @@ import kotlinx.coroutines.runBlocking @SuppressLint("UnsafeOptInUsageError") class CamConfig @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, - @Assisted private val effects: CameraSessionEffects, + @Assisted private val effects: ViewfinderEffects, private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, - private val cameraProviderSource: CameraProviderSource, - private val extensionAvailabilityStore: ExtensionAvailabilityStore, - private val featureCombinationSupport: FeatureCombinationSupport, - private val buildCameraSessionPlan: BuildCameraSessionPlan, - private val videoQualityFeatureMapper: VideoQualityFeatureMapper, - private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, + cameraSessionFactory: CameraSession.Factory, ) { @AssistedFactory @@ -92,7 +63,7 @@ class CamConfig @AssistedInject constructor( fun create( environment: CameraSessionEnvironment, - effects: CameraSessionEffects, + effects: ViewfinderEffects, ): CamConfig } @@ -118,95 +89,101 @@ class CamConfig @AssistedInject constructor( 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() - - // 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 snapshotSupport = 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 + val snapshotProbeCount: Int + get() { + return CameraSession.snapshotProbeCount + } @VisibleForTesting fun clearSnapshotProbeCache() { - snapshotSupport.clear() - snapshotProbeCount = 0 + CameraSession.clearSnapshotProbeCache() } - - // 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: ImageCaptureMode, - val selectHighestResolution: Boolean, - ) + private val session = cameraSessionFactory.create( + environment = environment, + listener = object : CameraSession.Listener { + override fun onZoomStateChanged() { + effects.updateZoomThumb() + } - var camera: Camera? = null + override fun onCameraProviderUnavailable() { + effects.showMessage(R.string.camera_provider_init_failure) + } - // 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 + override fun onExtensionsUnavailable() { + effects.showMessage(R.string.extensions_manager_init_failure) + } + + override fun onProviderReady(forced: Boolean) { + startCamera(forced = forced) + } - private var zoomStateSource: LiveData? = null + override fun onFeaturesSelected( + boundLensFacing: Int, + requested: List, + qualityFeature: GroupableFeature?, + selected: Set, + ) { + this@CamConfig.onFeaturesSelected( + boundLensFacing, requested, qualityFeature, selected, + ) + } + }, + ) - private val zoomStateObserver = Observer { - zoomState = it - if (it.linearZoom != 0f || it.zoomRatio != 1f) { - effects.updateZoomThumb() + var camera: Camera? + get() = session.camera + set(value) { + session.camera = value } - } - private val handler = Handler(Looper.getMainLooper()) + val zoomState: ZoomState? + get() = session.zoomState - private val attachZoomState = Runnable { - if (!environment.isSessionActive) return@Runnable + val cameraProvider: ProcessCameraProvider? + get() = session.cameraProvider - zoomStateSource = camera?.cameraInfo?.zoomState?.also { - it.observe(environment.sessionLifecycleOwner, zoomStateObserver) - } + val imageCapture: ImageCapture? + get() = session.imageCapture - zoomState = zoomStateSource?.value - } + val preview: Preview? + get() = session.preview - var cameraProvider: ProcessCameraProvider? = null - private var extensionsManager: ExtensionsManager? = null + val videoCapture: VideoCapture? + get() = session.videoCapture - var imageCapture: ImageCapture? = null - private set + val iAnalyzer: ImageAnalysis? + get() = session.iAnalyzer - var preview: Preview? = null + var lensFacing: Int + get() = session.lensFacing + set(value) { + session.lensFacing = value + } - val allowedFormats: ArrayList = arrayListOf() + val isFlashAvailable: Boolean + get() = session.isFlashAvailable - private val cameraExecutor by lazy { - Executors.newSingleThreadExecutor() - } + var isTorchOn: Boolean + get() = session.isTorchOn + set(value) { + session.isTorchOn = value + } + + val isZslSupported: Boolean + get() = session.isZslSupported - var videoCapture: VideoCapture? = null + fun canApplyVideoStabilization(): Boolean { + return session.canApplyVideoStabilization() + } - private var qrAnalyzer: QRAnalyzer? = null + fun toggleTorchState() { + session.toggleTorchState() + } - var iAnalyzer: ImageAnalysis? = null + val allowedFormats: ArrayList = arrayListOf() @set:VisibleForTesting var mPlayer = environment.createTunePlayer() @@ -292,22 +269,6 @@ class CamConfig @AssistedInject constructor( 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 @@ -333,12 +294,6 @@ class CamConfig @AssistedInject constructor( } } - var lensFacing = DEFAULT_LENS_FACING - - private var cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(DEFAULT_LENS_FACING) - .build() - var gridType: GridType by setting( read = { it.gridType }, write = { current, value -> current.copy(gridType = value) }, @@ -399,7 +354,7 @@ class CamConfig @AssistedInject constructor( effects.applyScanAllCodesChrome(value) } - qrAnalyzer?.refreshHints() + session.refreshQrHints() } var includeAudio: Boolean @@ -462,39 +417,6 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.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 } @@ -570,7 +492,7 @@ class CamConfig @AssistedInject constructor( } } - qrAnalyzer?.refreshHints() + session.refreshQrHints() } private fun slotCurrentMode() { @@ -613,7 +535,7 @@ class CamConfig @AssistedInject constructor( effects.selectBarcodeFormatToggles(allowedFormats) - qrAnalyzer?.refreshHints() + session.refreshQrHints() } var waitForFocusLock: Boolean by setting( @@ -626,10 +548,6 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(selectHighestResolution = value) }, ) - fun toggleTorchState() { - isTorchOn = !isTorchOn - } - fun toggleFlashMode() { if (isFlashAvailable) { @@ -655,10 +573,6 @@ class CamConfig @AssistedInject constructor( startCamera(true) } - private fun getCurrentCameraInfo(): CameraInfo { - return cameraProvider!!.getCameraInfo(cameraSelector) - } - fun toggleCameraSelector() { // Manually switch to the opposite lens facing @@ -671,7 +585,7 @@ class CamConfig @AssistedInject constructor( // 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)) { + if (session.isLensFacingSupported(lensFacing, currentMode.extensionMode)) { startCamera(true) } else { // Else revert back to the old facing (while displaying an error message @@ -687,209 +601,6 @@ class CamConfig @AssistedInject constructor( } - fun initializeCamera(forced: Boolean = false) { - if (cameraProvider != null) { - startCamera(forced = forced) - return - } - - cameraProviderSource.acquireProvider(environment.sessionContext) { provider -> - when (provider) { - null -> effects.showMessage(R.string.camera_provider_init_failure) - - else -> onCameraProviderReady(provider, forced) - } - } - } - - private fun onCameraProviderReady(provider: ProcessCameraProvider, forced: Boolean) { - 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. - extensionAvailabilityStore.clear() - snapshotSupport.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 - } - } - - cameraProviderSource.acquireExtensionsManager( - environment.sessionContext, - provider, - ) { manager -> - if (manager == null) { - effects.showMessage(R.string.extensions_manager_init_failure) - } else { - extensionsManager = manager - } - - startCamera(forced = 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 (ExtensionAvailabilityStore) 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 ExtensionAvailabilityStore). 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 = ExtensionKey( - lensFacing = lensFacing, - extensionMode = extensionMode, - ) - extensionAvailabilityStore.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 - extensionAvailabilityStore.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(): 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. private fun onFeaturesSelected( boundLensFacing: Int, requested: List, @@ -915,32 +626,11 @@ class CamConfig @AssistedInject constructor( ) } - 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 + fun initializeCamera(forced: Boolean = false) { + session.initialize(forced = forced, extensionMode = currentMode.extensionMode) } // Start the camera with latest hard configuration - @SuppressLint("RestrictedApi") fun startCamera(forced: Boolean = false) { if ((!forced && camera != null) || cameraProvider == null) return @@ -963,7 +653,7 @@ class CamConfig @AssistedInject constructor( // (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)) { + if (!session.isLensFacingSupported(lensFacing, currentMode.extensionMode)) { lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { CameraSelector.LENS_FACING_FRONT } else { @@ -972,272 +662,82 @@ class CamConfig @AssistedInject constructor( } - cameraSelector = CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build() + session.selectLensFacing(lensFacing) // To use the last frame instead of showing a blank screen when // the camera that is being currently used gets unbind effects.updateLastFrame() - // Unbind/close all other camera(s) [if any] - cameraProvider?.unbindAll() - - val extMode = currentMode.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 $currentMode 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 { - isVideoMode && enableEIS && canApplyVideoStabilization() -> { - FeatureGroupRequest.Requested(videoQualityAsGroupableFeature()) + val qrLensFacing = when { + isQRMode -> { + effects.startFocusTimer() + qrLensFacing() } - else -> FeatureGroupRequest.Unused + else -> null } - val requiredQualityFeature = when (featureGroup) { - is FeatureGroupRequest.Requested -> featureGroup.videoQualityFeature - FeatureGroupRequest.Unused -> null + if (isVideoMode) { + effects.setMicMutedIconVisible(!includeAudio) } - val bindRequest = CameraBindRequest( - includesVideoCapture = !isQRMode && isVideoMode, - includesImageCapture = !isQRMode && !entryPoint.requiresVideoModeOnly, + effects.forceUpdateOrientationSensor() + + val bindSettings = CameraBindSettings( + mode = currentMode, + isQrMode = isQRMode, + isVideoMode = isVideoMode, + requiresVideoModeOnly = entryPoint.requiresVideoModeOnly, + qrLensFacing = qrLensFacing, + rotation = rotation, aspectRatio = aspectRatio, - imageCaptureTargetRotation = imageCapture?.targetRotation ?: rotation, - previewTargetRotation = preview?.targetRotation ?: rotation, flashMode = flashMode, photoQuality = photoQuality, + videoQuality = videoQuality, waitForFocusLock = waitForFocusLock, enableZsl = enableZsl, + enableEis = enableEIS, selectHighestResolution = selectHighestResolution, - videoQuality = videoQuality, mirrorVideoOnFrontCamera = saveVideoAsPreviewed, - featureGroup = featureGroup, ) - val plan = buildCameraSessionPlan(bindRequest) + return when (session.bind(bindSettings)) { + CameraSession.BindOutcome.FAILED -> effects.showMessage(R.string.bind_failure) - if (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 - effects.startFocusTimer() - iAnalyzer = mIAnalyzer - mIAnalyzer.setAnalyzer(cameraExecutor, analyzer) - cameraSelector = CameraSelector.Builder() - .requireLensFacing( - if (isLensFacingSupported(CameraSelector.LENS_FACING_BACK)) { - CameraSelector.LENS_FACING_BACK - } else { - effects.showMessage(R.string.qr_rear_camera_unavailable) - CameraSelector.LENS_FACING_FRONT - } - ) - .build() - useCasesList.add(mIAnalyzer) - - } else { - if (isVideoMode) { - effects.setMicMutedIconVisible(!includeAudio) - } + CameraSession.BindOutcome.EXTENSION_UNUSABLE -> { + effects.showMessage(R.string.extension_mode_unavailable) - plan.videoCapture?.let { - videoCapture = it - useCasesList.add(it) + // 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) } - plan.imageCapture?.let { - imageCapture = it - useCasesList.add(it) - } - } - - preview = plan.preview.also { - useCasesList.add(it) - it.surfaceProvider = environment.previewSurfaceProvider + CameraSession.BindOutcome.BOUND -> announceBind() } + } - effects.forceUpdateOrientationSensor() - - // 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 (snapshotSupport) and every entry into video mode - // after the first is free. - val snapshotUseCase = imageCapture - if (isVideoMode && snapshotUseCase != null) { - val probeKey = SnapshotProbeKey( - lensFacing = lensFacing, - videoQuality = videoQuality, - usesFeatureGroup = featureGroup is FeatureGroupRequest.Requested, - captureMode = plan.captureMode, - selectHighestResolution = selectHighestResolution, + private fun qrLensFacing(): Int { + if (session.isLensFacingSupported( + lensFacing = CameraSelector.LENS_FACING_BACK, + extensionMode = currentMode.extensionMode, ) - - if (!snapshotSupport.containsKey(probeKey)) { - snapshotProbeCount++ - - val cameraInfo = try { - cameraProvider?.getCameraInfo(cameraSelector) - } catch (exception: IllegalArgumentException) { - Log.e(TAG, "Failed to query camera info", exception) - effects.showMessage(R.string.bind_failure) - return - } - - snapshotSupport[probeKey] = resolveInVideoSnapshotSupport( - videoQualityFeature = requiredQualityFeature, - probe = { withSnapshots, features -> - val probedUseCases = when { - withSnapshots -> useCasesList - else -> useCasesList - snapshotUseCase - } - - cameraInfo?.isSessionConfigSupported( - SessionConfig( - useCases = probedUseCases, - requiredFeatureGroup = features, - ) - ) == true - }, - ) - } - - val support = snapshotSupport[probeKey] - if (support is InVideoSnapshotSupport.Unsupported) { - Log.i(TAG, "${support.reason}; disabling snapshots while recording") - useCasesList.remove(snapshotUseCase) - imageCapture = null - } + ) { + return CameraSelector.LENS_FACING_BACK } - 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 -> - onFeaturesSelected(boundLensFacing, requested, requiredQualityFeature, selected) - } - } - - camera = cameraProvider!!.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) - effects.showMessage(R.string.bind_failure) - return - } - throw exception - } + effects.showMessage(R.string.qr_rear_camera_unavailable) - // 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) - extensionAvailabilityStore.record(key, usable = false) - 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) - return - } + return CameraSelector.LENS_FACING_FRONT + } + private fun announceBind() { 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) + session.reattachZoomState() effects.updateZoomThumb(false) @@ -1260,25 +760,10 @@ class CamConfig @AssistedInject constructor( private fun availableModes(): Set { return resolveAvailableModes( allowsQrScanning = entryPoint.allowsQrScanning, - extensionsAvailable = extensionsManager != null && cameraProvider != null, + extensionsAvailable = session.extensionsAvailable, ) } - private fun unprobedExtensions(): List { - if (extensionsManager == null || cameraProvider == null) return emptyList() - - return extensionAvailabilityStore.unprobed() - } - - private fun selectorFor(lensFacing: Int): CameraSelector { - return when (lensFacing) { - CameraSelector.LENS_FACING_FRONT -> FRONT_CAMERA_SELECTOR - else -> REAR_CAMERA_SELECTOR - } - } - - private var extensionProbesInFlight = false - private fun loadTabs() { if (!entryPoint.showsCameraModeTabs) { return @@ -1293,42 +778,7 @@ class CamConfig @AssistedInject constructor( // 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() - for (key in pending) { - verdicts[key] = probeExtension( - provider, em, selectorFor(key.lensFacing), key.extensionMode - ) - } - - environment.sessionMainExecutor.execute { - extensionProbesInFlight = false - if (!environment.isSessionActive) 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 - } - - extensionAvailabilityStore.recordProbeRound(verdicts) - buildTabs() - } - } + session.probeUnknownExtensions(onRestart = ::loadTabs, onSettled = ::buildTabs) } private fun buildTabs() { @@ -1414,7 +864,7 @@ class CamConfig @AssistedInject constructor( } } - qrAnalyzer?.refreshHints() + session.refreshQrHints() } fun onStorageLocationNotFound() { 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/repository/CameraSession.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt new file mode 100644 index 000000000..e531f79dc --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt @@ -0,0 +1,804 @@ +package app.grapheneos.camera.data.camera.repository + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Size +import androidx.annotation.VisibleForTesting +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.model.CameraBindSettings +import app.grapheneos.camera.data.camera.model.ExtensionKey +import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore +import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper +import app.grapheneos.camera.domain.camera.model.CameraBindRequest +import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest +import app.grapheneos.camera.domain.camera.model.ImageCaptureMode +import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan +import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.util.concurrent.Executors +import kotlin.concurrent.thread + +@SuppressLint("UnsafeOptInUsageError") +class CameraSession @AssistedInject constructor( + @Assisted private val environment: CameraSessionEnvironment, + @Assisted private val listener: Listener, + private val cameraProviderSource: CameraProviderSource, + private val extensionAvailabilityStore: ExtensionAvailabilityStore, + private val featureCombinationSupport: FeatureCombinationSupport, + private val buildCameraSessionPlan: BuildCameraSessionPlan, + private val videoQualityFeatureMapper: VideoQualityFeatureMapper, + private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, +) { + + interface Listener { + + fun onZoomStateChanged() + + fun onCameraProviderUnavailable() + + fun onExtensionsUnavailable() + + fun onProviderReady(forced: Boolean) + + fun onFeaturesSelected( + boundLensFacing: Int, + requested: List, + qualityFeature: GroupableFeature?, + selected: Set, + ) + } + + @AssistedFactory + interface Factory { + + fun create( + environment: CameraSessionEnvironment, + listener: Listener, + ): CameraSession + } + + private data class SnapshotProbeKey( + val lensFacing: Int, + val videoQuality: Quality, + val usesFeatureGroup: Boolean, + val captureMode: ImageCaptureMode, + val selectHighestResolution: Boolean, + ) + + enum class BindOutcome { + BOUND, + FAILED, + EXTENSION_UNUSABLE, + } + + 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) { + 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 + } + + var cameraProvider: ProcessCameraProvider? = null + + private var extensionsManager: ExtensionsManager? = null + + var imageCapture: ImageCapture? = null + + var preview: Preview? = null + + private val cameraExecutor by lazy { + Executors.newSingleThreadExecutor() + } + + var videoCapture: VideoCapture? = null + + private var qrAnalyzer: QRAnalyzer? = null + + val extensionsAvailable: Boolean + get() { + return extensionsManager != null && cameraProvider != null + } + + var iAnalyzer: ImageAnalysis? = null + + 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 lensFacing = DEFAULT_LENS_FACING + + private var cameraSelector: CameraSelector = CameraSelector.Builder() + .requireLensFacing(DEFAULT_LENS_FACING) + .build() + + val isZslSupported: Boolean by lazy { + camera!!.cameraInfo.isZslSupported + } + + private var extensionProbesInFlight = false + + fun refreshQrHints() { + qrAnalyzer?.refreshHints() + } + + 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. + 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. + 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 toggleTorchState() { + isTorchOn = !isTorchOn + } + + private fun getCurrentCameraInfo(): CameraInfo { + return cameraProvider!!.getCameraInfo(cameraSelector) + } + + 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 (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. + extensionAvailabilityStore.clear() + snapshotSupport.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, extensionMode)) { + lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + } + } + + cameraProviderSource.acquireExtensionsManager( + environment.sessionContext, + provider, + ) { manager -> + if (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 (ExtensionAvailabilityStore) 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 ExtensionAvailabilityStore). 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. + 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, em, selectorFor(key.lensFacing), key.extensionMode + ) + } + + environment.sessionMainExecutor.execute { + extensionProbesInFlight = false + if (!environment.isSessionActive) 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 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 + } + + extensionAvailabilityStore.recordProbeRound(verdicts) + onSettled() + } + } + } + + 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 = ExtensionKey( + lensFacing = lensFacing, + extensionMode = extensionMode, + ) + extensionAvailabilityStore.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 + extensionAvailabilityStore.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. + + 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 (e: IllegalArgumentException) { + return false + } + } + } + + return cameraProvider?.hasCamera(tCameraSelector) ?: false + } + + @SuppressLint("RestrictedApi") + fun bind(settings: CameraBindSettings): BindOutcome { + // Unbind/close all other camera(s) [if any] + cameraProvider?.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(settings.videoQuality) 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 = buildCameraSessionPlan(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 (snapshotSupport) 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 (!snapshotSupport.containsKey(probeKey)) { + snapshotProbeCount++ + + val cameraInfo = try { + cameraProvider?.getCameraInfo(cameraSelector) + } catch (exception: IllegalArgumentException) { + Log.e(TAG, "Failed to query camera info", exception) + return BindOutcome.FAILED + } + + snapshotSupport[probeKey] = resolveInVideoSnapshotSupport( + videoQualityFeature = requiredQualityFeature, + probe = { withSnapshots, features -> + val probedUseCases = when { + withSnapshots -> useCasesList + else -> useCasesList - snapshotUseCase + } + + cameraInfo?.isSessionConfigSupported( + SessionConfig( + useCases = probedUseCases, + requiredFeatureGroup = features, + ) + ) == true + }, + ) + } + + val support = snapshotSupport[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 = cameraProvider!!.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) + extensionAvailabilityStore.record(key, usable = false) + + return BindOutcome.EXTENSION_UNUSABLE + } + + return BindOutcome.BOUND + } + + private fun unprobedExtensions(): List { + if (extensionsManager == null || cameraProvider == null) return emptyList() + + return extensionAvailabilityStore.unprobed() + } + + private fun selectorFor(lensFacing: Int): CameraSelector { + return when (lensFacing) { + CameraSelector.LENS_FACING_FRONT -> FRONT_CAMERA_SELECTOR + else -> REAR_CAMERA_SELECTOR + } + } + + companion object { + private const val TAG = "CameraSession" + + const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK + + val FRONT_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .build() + + val REAR_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + // 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 snapshotSupport = 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() { + snapshotSupport.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 + } +} diff --git a/app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt similarity index 86% rename from app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt index fae25671a..5df457b3d 100644 --- a/app/src/main/java/app/grapheneos/camera/CameraSessionEnvironment.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt @@ -1,8 +1,9 @@ -package app.grapheneos.camera +package app.grapheneos.camera.data.camera.repository 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 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 03628dc1b..0e355419c 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 @@ -72,6 +72,7 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins import app.grapheneos.camera.App import app.grapheneos.camera.CamConfig +import app.grapheneos.camera.ui.viewfinder.ViewfinderEffectHandler import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R @@ -787,7 +788,7 @@ open class MainActivity : AppCompatActivity(), gestureDetector = GestureDetector(this, this) - val sessionHandler = CameraSessionHandler(this) + val sessionHandler = ViewfinderEffectHandler(this) camConfig = camConfigFactory.create( environment = sessionHandler, effects = sessionHandler, diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt similarity index 97% rename from app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt rename to app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt index 9a78fa01f..904e9010c 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/CameraSessionHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.ui.activities +package app.grapheneos.camera.ui.viewfinder import android.content.Context import android.os.Build @@ -13,8 +13,8 @@ import androidx.camera.core.Preview import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import app.grapheneos.camera.App -import app.grapheneos.camera.CameraSessionEffects -import app.grapheneos.camera.CameraSessionEnvironment +import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment +import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.R import app.grapheneos.camera.TunePlayer import app.grapheneos.camera.analyzer.QRAnalyzer @@ -25,9 +25,49 @@ import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog import com.google.zxing.BarcodeFormat import java.util.concurrent.Executor -internal class CameraSessionHandler( +internal class ViewfinderEffectHandler( private val activity: MainActivity, -) : CameraSessionEnvironment, CameraSessionEffects { +) : CameraSessionEnvironment, ViewfinderEffects { + + 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 ?: @Suppress("DEPRECATION") + activity.windowManager.defaultDisplay.rotation + } + + // 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 -> @Suppress("DEPRECATION") activity.windowManager.defaultDisplay.rotation + } + } override fun showMessage(@StringRes message: Int) { activity.showMessage(message) @@ -57,21 +97,6 @@ internal class CameraSessionHandler( activity.locationCamConfigChanged(required) } - override val sessionContext: Context - get() { - return activity - } - - override val sessionLifecycleOwner: LifecycleOwner - get() { - return activity - } - - override val sessionMainExecutor: Executor - get() { - return ContextCompat.getMainExecutor(activity) - } - override fun shouldAskForLocationPermission(): Boolean { return (activity.applicationContext as App).shouldAskForLocationPermission() } @@ -101,31 +126,6 @@ internal class CameraSessionHandler( showStorageLocationNotFoundDialog(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 ?: @Suppress("DEPRECATION") - activity.windowManager.defaultDisplay.rotation - } - - // 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 -> @Suppress("DEPRECATION") activity.windowManager.defaultDisplay.rotation - } - } - override fun cancelPendingCapture() { activity.imageCapturer.cancelPendingCaptureRequest() } diff --git a/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt similarity index 95% rename from app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt rename to app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt index a06db6f88..8d742968c 100644 --- a/app/src/main/java/app/grapheneos/camera/CameraSessionEffects.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera +package app.grapheneos.camera.ui.viewfinder import androidx.annotation.StringRes import androidx.camera.core.CameraInfo @@ -6,7 +6,7 @@ import androidx.camera.core.ExposureState import app.grapheneos.camera.data.core.model.CameraMode import com.google.zxing.BarcodeFormat -interface CameraSessionEffects { +interface ViewfinderEffects { fun showMessage(@StringRes message: Int) From c3e81a1cd0ffb16679cccb5938ec4c43fe074419 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Sun, 6 Sep 2026 23:23:10 +0200 Subject: [PATCH 14/23] Move gestures and orientation out of MainActivity --- app/config/ktlint/baseline.xml | 216 +--- app/detekt-baseline-debug.xml | 41 +- .../camera/ActivityLifeCycleHelper.kt | 20 +- .../main/java/app/grapheneos/camera/App.kt | 33 +- .../java/app/grapheneos/camera/CamConfig.kt | 340 +++--- .../data/camera/repository/CameraSession.kt | 60 +- .../preferences/PreferencesProvidesModule.kt | 9 +- .../preferences/SecureSessionPreferences.kt | 44 + .../camera/domain/qr/BarcodeFormats.kt | 106 ++ .../camera/ui/activities/MainActivity.kt | 968 +++++------------- .../camera/ui/activities/MoreSettings.kt | 144 +-- .../ui/viewfinder/ViewfinderEffectHandler.kt | 19 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 247 +++++ .../ViewfinderOrientationHandler.kt | 217 ++++ app/src/main/res/values/strings.xml | 2 - .../PreferencesProvidesModuleTest.kt | 4 + .../SecureSessionPreferencesTest.kt | 108 ++ .../usecase/ResolveAvailableModesImplTest.kt | 9 +- .../camera/domain/qr/BarcodeFormatsTest.kt | 98 ++ 19 files changed, 1418 insertions(+), 1267 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt create mode 100644 app/src/test/java/app/grapheneos/camera/di/preferences/SecureSessionPreferencesTest.kt create mode 100644 app/src/test/java/app/grapheneos/camera/domain/qr/BarcodeFormatsTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index a5b592274..6ea38ab4b 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -90,20 +90,6 @@ - - - - - - - - - - - - - - @@ -112,22 +98,6 @@ - - - - - - - - - - - - - - - - @@ -337,24 +307,6 @@ - - - - - - - - - - - - - - - - - - @@ -577,145 +529,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - + @@ -776,13 +596,6 @@ - - - - - - - @@ -815,23 +628,4 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index ef309c539..582f6061c 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -8,18 +8,17 @@ 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: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 @@ -32,14 +31,13 @@ InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is IllegalArgumentException InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is UnsupportedOperationException InstanceOfCheckForException:CameraSession.kt:CameraSession$exception is IllegalArgumentException - LargeClass:MainActivity.kt:MainActivity : AppCompatActivityOnTouchListenerOnScaleGestureListenerOnGestureListenerOnDoubleTapListenerListener + LargeClass:MainActivity.kt:MainActivity : AppCompatActivity LongMethod:BlurBitmap.kt:BlurBitmap$operator fun get: Bitmap LongMethod:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") 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 @@ -74,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 @@ -103,12 +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: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 @@ -117,14 +116,22 @@ 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$fun startCamera ReturnCount:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome @@ -135,14 +142,14 @@ 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? + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$override fun onTouch: Boolean + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeLeft + ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeRight SwallowedException:CameraSession.kt:CameraSession$e: IllegalArgumentException SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception SwallowedException:CapturedItems.kt:e: ActivityNotFoundException @@ -161,19 +168,16 @@ 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:CameraSession.kt:CameraSession$camera!! - UnsafeCallOnNullableType:CameraSession.kt:CameraSession$cameraProvider!! UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! UnsafeCallOnNullableType:ImageSaver.kt:ImageSaver$DocumentsContract.createDocument(contentResolver, treeDocumentUri, mimeType(), fileName())!! @@ -182,10 +186,7 @@ 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/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 index c65384642..cbb7f0a06 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -18,20 +18,21 @@ import androidx.camera.video.Quality import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture import app.grapheneos.camera.data.camera.model.CameraBindSettings -import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment import app.grapheneos.camera.data.camera.repository.CameraSession +import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment 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.ui.videoQualityTitle -import app.grapheneos.camera.ui.viewfinder.ViewfinderEffects 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.qr.BarcodeFormats +import app.grapheneos.camera.ui.videoQualityTitle +import app.grapheneos.camera.ui.viewfinder.ViewfinderEffects import com.google.zxing.BarcodeFormat import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -55,52 +56,10 @@ class CamConfig @AssistedInject constructor( private val capturedItemRepository: CapturedItemRepository, private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, + private val barcodeFormats: BarcodeFormats, cameraSessionFactory: CameraSession.Factory, ) { - @AssistedFactory - interface Factory { - - fun create( - environment: CameraSessionEnvironment, - effects: ViewfinderEffects, - ): CamConfig - } - - companion object { - private const val TAG = "CamConfig" - - 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 - - @VisibleForTesting - val snapshotProbeCount: Int - get() { - return CameraSession.snapshotProbeCount - } - - @VisibleForTesting - fun clearSnapshotProbeCache() { - CameraSession.clearSnapshotProbeCache() - } - } - private val session = cameraSessionFactory.create( environment = environment, listener = object : CameraSession.Listener { @@ -127,7 +86,10 @@ class CamConfig @AssistedInject constructor( selected: Set, ) { this@CamConfig.onFeaturesSelected( - boundLensFacing, requested, qualityFeature, selected, + boundLensFacing, + requested, + qualityFeature, + selected, ) } }, @@ -175,15 +137,8 @@ class CamConfig @AssistedInject constructor( val isZslSupported: Boolean get() = session.isZslSupported - fun canApplyVideoStabilization(): Boolean { - return session.canApplyVideoStabilization() - } - - fun toggleTorchState() { - session.toggleTorchState() - } - - val allowedFormats: ArrayList = arrayListOf() + val allowedFormats: List + get() = barcodeFormats.enabled @set:VisibleForTesting var mPlayer = environment.createTunePlayer() @@ -196,21 +151,6 @@ class CamConfig @AssistedInject constructor( private var modeSettings: ModeSettings = ModeSettings() - private fun setting( - read: (CameraSettings) -> T, - write: (CameraSettings, T) -> CameraSettings, - ): ReadWriteProperty { - return object : ReadWriteProperty { - override fun getValue(thisRef: Any?, property: KProperty<*>): T { - return read(settings) - } - - override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - settings = runBlocking { settingsRepository.update { write(it, value) } } - } - } - } - private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) var lastCapturedItem: CapturedItem? = null @@ -238,23 +178,6 @@ class CamConfig @AssistedInject constructor( } } - 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 && entryPoint.isVideoOnlySession - - lastCapturedItem = if (skip) null else item - } - - var isVideoMode = false private set get() { @@ -275,17 +198,9 @@ class CamConfig @AssistedInject constructor( var aspectRatio: Int get() { return when { - isVideoMode -> { - AspectRatio.RATIO_16_9 - } - - isQRMode -> { - AspectRatio.RATIO_4_3 - } - - else -> { - settings.aspectRatio - } + isVideoMode -> AspectRatio.RATIO_16_9 + isQRMode -> AspectRatio.RATIO_4_3 + else -> settings.aspectRatio } } set(value) { @@ -312,20 +227,6 @@ class CamConfig @AssistedInject constructor( 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 - effects.onFlashModeChanged() - } - var focusTimeout: Long by setting( read = { it.focusTimeoutSeconds }, write = { current, value -> current.copy(focusTimeoutSeconds = value) }, @@ -374,21 +275,11 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(enableEis = value) }, ) - var enableZsl: Boolean by setting( - read = { it.enableZsl }, - write = { current, value -> current.copy(enableZsl = value) }, - ) - var saveImageAsPreviewed: Boolean by setting( read = { it.saveImageAsPreviewed }, write = { current, value -> current.copy(saveImageAsPreviewed = value) }, ) - var saveVideoAsPreviewed: Boolean by setting( - read = { it.saveVideoAsPreviewed }, - write = { current, value -> current.copy(saveVideoAsPreviewed = value) }, - ) - var storageLocation: String get() { return currentStorageLocation @@ -412,15 +303,6 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(removeExifAfterCapture = value) }, ) - var gSuggestions: Boolean by setting( - read = { it.gyroscopeSuggestions }, - write = { current, value -> current.copy(gyroscopeSuggestions = value) }, - ) - - fun shouldShowGyroscope(): Boolean { - return isInPhotoMode && gSuggestions - } - private val isInPhotoMode: Boolean get() { return !(isQRMode || isVideoMode) @@ -431,16 +313,6 @@ class CamConfig @AssistedInject constructor( return entryPoint.isCaptureSession } - 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. @@ -472,25 +344,82 @@ class CamConfig @AssistedInject constructor( effects.onSelfIlluminationChanged(value) } - fun setQRScanningFor(format: String, selected: Boolean) { + var waitForFocusLock: Boolean by setting( + read = { it.waitForFocusLock }, + write = { current, value -> current.copy(waitForFocusLock = value) }, + ) - settings = runBlocking { - settingsRepository.update { - it.withBarcodeFormat(formatName = format, enabled = selected) - } - } + fun canApplyVideoStabilization(): Boolean { + return session.canApplyVideoStabilization() + } + + fun toggleTorchState() { + session.toggleTorchState() + } - if (selected) { - if (BarcodeFormat.valueOf(format) !in allowedFormats) { - allowedFormats.add(BarcodeFormat.valueOf(format)) + private fun setting( + read: (CameraSettings) -> T, + write: (CameraSettings, T) -> CameraSettings, + ): ReadWriteProperty { + return object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + return read(settings) } - } else { - if (allowedFormats.size == 1) { - effects.showMessage(R.string.no_barcode_selected) - } else { - allowedFormats.remove(BarcodeFormat.valueOf(format)) + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + settings = runBlocking { settingsRepository.update { write(it, value) } } } } + } + + 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 && entryPoint.isVideoOnlySession + + lastCapturedItem = if (skip) null else item + } + + fun setFlashMode(value: Int) { + runBlocking { + settingsRepository.setFlashMode(value) + }?.let { modeSettings = it } + + applyFlashMode(value) + } + + private fun applyFlashMode(value: Int) { + flashMode = value + imageCapture?.flashMode = value + effects.onFlashModeChanged() + } + + fun shouldShowGyroscope(): Boolean { + return isInPhotoMode && settings.gyroscopeSuggestions + } + + 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) + } + } + + fun setQRScanningFor(format: String, selected: Boolean) { + if (!barcodeFormats.setEnabled(formatName = format, enabled = selected)) { + effects.showMessage(R.string.no_barcode_selected) + } session.refreshQrHints() } @@ -528,29 +457,15 @@ class CamConfig @AssistedInject constructor( fun loadSettings() { includeAudio = settings.includeAudio - allowedFormats.clear() - allowedFormats.addAll( - BarcodeFormat.entries.filter { it.name in settings.enabledBarcodeFormats }, - ) + barcodeFormats.load(settings.enabledBarcodeFormats) effects.selectBarcodeFormatToggles(allowedFormats) session.refreshQrHints() } - var waitForFocusLock: Boolean by setting( - read = { it.waitForFocusLock }, - write = { current, value -> current.copy(waitForFocusLock = value) }, - ) - - var selectHighestResolution: Boolean by setting( - read = { it.selectHighestResolution }, - write = { current, value -> current.copy(selectHighestResolution = value) }, - ) - fun toggleFlashMode() { if (isFlashAvailable) { - val next = when (flashMode) { ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO @@ -558,7 +473,6 @@ class CamConfig @AssistedInject constructor( } setFlashMode(next) - } else { effects.showMessage(R.string.flash_unavailable_in_selected_mode) } @@ -574,14 +488,13 @@ class CamConfig @AssistedInject constructor( } fun toggleCameraSelector() { - // Manually switch to the opposite lens facing lensFacing = - if (lensFacing == CameraSelector.LENS_FACING_BACK) + if (lensFacing == CameraSelector.LENS_FACING_BACK) { CameraSelector.LENS_FACING_FRONT - else + } 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 @@ -598,7 +511,6 @@ class CamConfig @AssistedInject constructor( CameraSelector.LENS_FACING_BACK } } - } private fun onFeaturesSelected( @@ -661,7 +573,6 @@ class CamConfig @AssistedInject constructor( } } - session.selectLensFacing(lensFacing) // To use the last frame instead of showing a blank screen when @@ -695,10 +606,10 @@ class CamConfig @AssistedInject constructor( photoQuality = photoQuality, videoQuality = videoQuality, waitForFocusLock = waitForFocusLock, - enableZsl = enableZsl, + enableZsl = settings.enableZsl, enableEis = enableEIS, - selectHighestResolution = selectHighestResolution, - mirrorVideoOnFrontCamera = saveVideoAsPreviewed, + selectHighestResolution = settings.selectHighestResolution, + mirrorVideoOnFrontCamera = settings.saveVideoAsPreviewed, ) return when (session.bind(bindSettings)) { @@ -819,51 +730,23 @@ class CamConfig @AssistedInject constructor( } fun showMoreOptionsForQR() { - val optionNames = BarcodeFormat.entries - .filterNot { it in commonFormats } - .map { it.name } + val optionNames = barcodeFormats.uncommonNames() effects.showBarcodeFormatPicker( optionNames = optionNames, - initialValues = optionNames.map { it in settings.enabledBarcodeFormats }, + initialValues = optionNames.map { barcodeFormats.isEnabled(it) }, onConfirm = { values -> applyBarcodeFormats(optionNames, values) }, ) } private fun applyBarcodeFormats(optionNames: List, values: List) { - // 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 = commonFormats.none { allowedFormats.contains(it) } + val selection = optionNames.withIndex().associate { (index, name) -> name to values[index] } - if (allCommonFormatsDisabled && values.none { it }) { + if (!barcodeFormats.apply(selection)) { effects.showMessage(R.string.no_barcode_selected) return } - for ((index, optionName) in optionNames.withIndex()) { - val format = BarcodeFormat.valueOf(optionName) - - if (values[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 = values[index], - ) - } - } - } - session.refreshQrHints() } @@ -873,4 +756,35 @@ class CamConfig @AssistedInject constructor( effects.showStorageLocationNotFound() } + + @AssistedFactory + interface Factory { + fun create( + environment: CameraSessionEnvironment, + effects: ViewfinderEffects, + ): CamConfig + } + + companion object { + private const val TAG = "CamConfig" + + val DEFAULT_CAMERA_MODE = CameraMode.CAMERA + + 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" } + + @VisibleForTesting + val snapshotProbeCount: Int + get() = CameraSession.snapshotProbeCount + + @VisibleForTesting + fun clearSnapshotProbeCache() { + CameraSession.clearSnapshotProbeCache() + } + } } diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt index e531f79dc..77a5d1516 100644 --- a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt @@ -175,7 +175,9 @@ class CameraSession @AssistedInject constructor( .build() val isZslSupported: Boolean by lazy { - camera!!.cameraInfo.isZslSupported + requireNotNull(camera) { + "Queried before the first bind" + }.cameraInfo.isZslSupported } private var extensionProbesInFlight = false @@ -237,7 +239,11 @@ class CameraSession @AssistedInject constructor( } private fun getCurrentCameraInfo(): CameraInfo { - return cameraProvider!!.getCameraInfo(cameraSelector) + val provider = requireNotNull(cameraProvider) { + "Camera provider is not ready yet" + } + + return provider.getCameraInfo(cameraSelector) } fun initialize(forced: Boolean, extensionMode: Int) { @@ -345,7 +351,10 @@ class CameraSession @AssistedInject constructor( val verdicts = HashMap() for (key in pending) { verdicts[key] = probeExtension( - provider, em, selectorFor(key.lensFacing), key.extensionMode + provider = provider, + em = em, + selector = selectorFor(key.lensFacing), + extensionMode = key.extensionMode ) } @@ -504,8 +513,9 @@ class CameraSession @AssistedInject constructor( if (extensionMode != ExtensionMode.NONE) { extensionsManager?.let { em -> - if (!isExtensionUsable(tCameraSelector, lensFacing, extensionMode)) + if (!isExtensionUsable(tCameraSelector, lensFacing, extensionMode)) { return false + } try { tCameraSelector = em.getExtensionEnabledCameraSelector( @@ -523,8 +533,12 @@ class CameraSession @AssistedInject constructor( @SuppressLint("RestrictedApi") fun bind(settings: CameraBindSettings): BindOutcome { + val provider = requireNotNull(cameraProvider) { + "Camera provider is not ready yet" + } + // Unbind/close all other camera(s) [if any] - cameraProvider?.unbindAll() + provider.unbindAll() val extMode = settings.mode.extensionMode var appliedExtension: ExtensionKey? = null @@ -552,7 +566,7 @@ class CameraSession @AssistedInject constructor( // // 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(settings.videoQuality) can legitimately fail + // 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. // @@ -561,11 +575,10 @@ class CameraSession @AssistedInject constructor( // 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. + // 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)) @@ -613,10 +626,14 @@ class CameraSession @AssistedInject constructor( iAnalyzer = mIAnalyzer mIAnalyzer.setAnalyzer(cameraExecutor, analyzer) cameraSelector = CameraSelector.Builder() - .requireLensFacing(requireNotNull(settings.qrLensFacing) { "QR mode needs a lens facing" }) + .requireLensFacing( + requireNotNull(settings.qrLensFacing) { + "QR mode needs a lens facing" + } + ) .build() - useCasesList.add(mIAnalyzer) + useCasesList.add(mIAnalyzer) } else { plan.videoCapture?.let { videoCapture = it @@ -659,7 +676,7 @@ class CameraSession @AssistedInject constructor( snapshotProbeCount++ val cameraInfo = try { - cameraProvider?.getCameraInfo(cameraSelector) + provider.getCameraInfo(cameraSelector) } catch (exception: IllegalArgumentException) { Log.e(TAG, "Failed to query camera info", exception) return BindOutcome.FAILED @@ -673,12 +690,12 @@ class CameraSession @AssistedInject constructor( else -> useCasesList - snapshotUseCase } - cameraInfo?.isSessionConfigSupported( + cameraInfo.isSessionConfigSupported( SessionConfig( useCases = probedUseCases, requiredFeatureGroup = features, ) - ) == true + ) }, ) } @@ -703,7 +720,7 @@ class CameraSession @AssistedInject constructor( sessionConfig.setFeatureSelectionListener( environment.sessionMainExecutor ) { selected -> - listener.onFeaturesSelected( + listener.onFeaturesSelected( boundLensFacing = boundLensFacing, requested = requested, qualityFeature = requiredQualityFeature, @@ -712,8 +729,9 @@ class CameraSession @AssistedInject constructor( } } - camera = cameraProvider!!.bindToLifecycle( - environment.sessionLifecycleOwner, cameraSelector, + camera = provider.bindToLifecycle( + environment.sessionLifecycleOwner, + cameraSelector, sessionConfig ) } catch (exception: RuntimeException) { @@ -740,7 +758,9 @@ class CameraSession @AssistedInject constructor( // 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) { + if (exception !is UnsupportedOperationException && + exception !is IllegalArgumentException + ) { throw exception } 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..f17db6d99 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,7 +2,6 @@ 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.store.SettingsPrefs import app.grapheneos.camera.di.core.DurablePreferences @@ -13,8 +12,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 +22,11 @@ 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 } } @@ -38,9 +36,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..3a8a14ec8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt @@ -0,0 +1,44 @@ +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.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 openActivities = 0 + + fun settingsSnapshotOf(durable: DataStore): DataStore { + return settings ?: snapshotOf(durable).also { settings = 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 + } + } + + private fun snapshotOf(durable: DataStore): DataStore { + return InMemoryDataStore(runBlocking { durable.data.first() }) + } +} 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..9f1076b6f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt @@ -0,0 +1,106 @@ +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.runBlocking + +@ActivityScoped +class BarcodeFormats @Inject constructor( + private val settingsRepository: SettingsRepository, +) { + + private val enabledFormats = arrayListOf() + + val enabled: List + get() { + return enabledFormats + } + + 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/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt index 0e355419c..89e0a07ee 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 @@ -57,22 +54,24 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.camera.core.FocusMeteringAction import androidx.camera.core.MeteringPointFactory -import androidx.camera.core.Preview import androidx.camera.core.SurfaceOrientedMeteringPointFactory import androidx.camera.view.PreviewView 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 app.grapheneos.camera.App import app.grapheneos.camera.CamConfig -import app.grapheneos.camera.ui.viewfinder.ViewfinderEffectHandler import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R @@ -81,12 +80,12 @@ import app.grapheneos.camera.capturer.VideoCapturer import app.grapheneos.camera.capturer.getVideoThumbnail import app.grapheneos.camera.data.core.model.CameraMode 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.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 @@ -96,6 +95,9 @@ 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.viewfinder.ViewfinderEffectHandler +import app.grapheneos.camera.ui.viewfinder.ViewfinderGestureHandler +import app.grapheneos.camera.ui.viewfinder.ViewfinderOrientationHandler import app.grapheneos.camera.util.CameraControl import app.grapheneos.camera.util.ImageResizer import app.grapheneos.camera.util.executeIfAlive @@ -108,27 +110,15 @@ 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 @AndroidEntryPoint -open class MainActivity : AppCompatActivity(), - OnTouchListener, - OnScaleGestureListener, - GestureDetector.OnGestureListener, - GestureDetector.OnDoubleTapListener, - SensorOrientationChangeNotifier.Listener { +open class MainActivity : AppCompatActivity() { @Inject lateinit var camConfigFactory: CamConfig.Factory @@ -141,17 +131,33 @@ open class MainActivity : AppCompatActivity(), private val application: App get() = applicationContext as App - private lateinit var binding: ActivityMainBinding + internal lateinit var binding: ActivityMainBinding + + internal val gestureHandler by lazy { ViewfinderGestureHandler(this) } + internal val orientationHandler by lazy { ViewfinderOrientationHandler(this) } + + fun onDeviceAngleChange(xDegrees: Float, zDegrees: Float) { + orientationHandler.onDeviceAngleChange(xDegrees, zDegrees) + } + + val gestureDetector: GestureDetector + get() = gestureHandler.gestureDetector + + val threeButtons: View + get() = binding.threeButtons private val cameraPermission = arrayOf(Manifest.permission.CAMERA) - lateinit var previewView: PreviewView - lateinit var previewContainer: ConstraintLayout - lateinit var bottomOverlay: View + val previewView: PreviewView + get() = binding.preview + + val bottomOverlay: View + get() = binding.bottomOverlay // 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 @@ -172,99 +178,109 @@ open class MainActivity : AppCompatActivity(), // Whether the transition still is standing in for the preview. private var transitionShown = false - private lateinit var mainFrame: View - lateinit var rootView: View + val rootView: View + get() = binding.root + + val qrScanToggles: View + get() = binding.qrScanToggles + + val qrToggle: QRToggle + get() = binding.qrScanToggle - lateinit var qrScanToggles: View - private lateinit var moreOptionsToggle: View + val dmToggle: QRToggle + get() = binding.dataMatrixToggle - lateinit var qrToggle: QRToggle - lateinit var dmToggle: QRToggle - lateinit var cBToggle: QRToggle - lateinit var azToggle: QRToggle + val cBToggle: QRToggle + get() = binding.pdf417Toggle + + val azToggle: QRToggle + get() = binding.aztecToggle lateinit var imageCapturer: ImageCapturer + lateinit var videoCapturer: VideoCapturer - lateinit var flipCameraCircle: View - lateinit var cancelButtonView: ImageView - lateinit var tabLayout: BottomTabLayout - lateinit var thirdCircle: ImageView - lateinit var captureButton: ImageButton + val flipCameraCircle: View + get() = binding.flipCameraCircle - 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 cancelButtonView: ImageView + get() = binding.cancelButton - lateinit var zoomBar: ZoomBar - lateinit var zoomBarPanel: LinearLayout + val tabLayout: BottomTabLayout + get() = binding.cameraModeTabs - lateinit var exposureBar: ExposureBar - lateinit var exposureBarPanel: LinearLayout + val captureButton: ImageButton + get() = binding.captureButton - lateinit var qrOverlay: QROverlay + val timerView: TextView + get() = binding.timer - lateinit var threeButtons: LinearLayout + val thirdOption: View + get() = binding.thirdOption - lateinit var settingsIcon: ImageView + val imagePreview: ShapeableImageView + get() = binding.imagePreview - private lateinit var exposurePlusIcon: ImageView - private lateinit var exposureNegIcon: ImageView + val previewLoader: ProgressBar + get() = binding.previewLoading - private lateinit var zoomInIcon: ImageView - private lateinit var zoomOutIcon: ImageView + val zoomBar: ZoomBar + get() = binding.zoomBar - lateinit var flipCamIcon: ImageView + val zoomBarPanel: LinearLayout + get() = binding.zoomBarPanel - lateinit var mainOverlay: ImageView + val exposureBar: ExposureBar + get() = binding.exposureBar - lateinit var settingsDialog: SettingsDialog - lateinit var previewGrid: CustomGrid + val exposureBarPanel: LinearLayout + get() = binding.exposureBarPanel - private var wasSwiping = false + val qrOverlay: QROverlay + get() = binding.qrOverlay - lateinit var cdTimer: CountDownTimerUI - var timerDuration = 0 + val settingsIcon: ImageView + get() = binding.settingsOption - lateinit var cbText: TextView - lateinit var cbCross: ImageView + val mainOverlay: ImageView + get() = binding.mainOverlay - lateinit var gCircleFrame: FrameLayout + lateinit var settingsDialog: SettingsDialog - 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 + var timerDuration = 0 - lateinit var muteToggle: ShapeableImageView + val cbText: TextView + get() = binding.captureButtonText - private var bottomNavigationBarPadding: Int = 0 + val cbCross: ImageView + get() = binding.captureButtonCross - private var shouldGyroVibrate = true - private var hasGyroVibrated = false + val gCircleFrame: FrameLayout + get() = binding.gCircleFrame - private val gyroVibRunnable = Runnable { - vibrateDevice() - hasGyroVibrated = true - } + val muteToggle: ShapeableImageView + get() = binding.muteToggle + + private var bottomNavigationBarPadding: Int = 0 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( @@ -278,14 +294,14 @@ 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 + binding.focusRing.visibility = View.INVISIBLE } - lateinit var micOffIcon: ImageView + val micOffIcon: ImageView + get() = binding.micOff private var shouldRestartRecording = false @@ -315,9 +331,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.") @@ -325,12 +339,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.") @@ -338,24 +347,6 @@ open class MainActivity : AppCompatActivity(), } } - // Used to request permission from the user - var dirPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) - { result -> - - val data: Uri? = result.data?.data - - 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)) - } - } - - Log.i(TAG, "Selected location: ${data?.encodedPath!!}") - } - private fun showAudioPermissionDeniedDialog(onDisableAudio: () -> Unit = {}) { val builder = MaterialAlertDialogBuilder(this) .setTitle(R.string.audio_permission_dialog_title) @@ -366,7 +357,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) @@ -495,7 +487,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 @@ -512,13 +507,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() @@ -526,12 +520,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) @@ -542,9 +536,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 @@ -563,15 +556,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 @@ -589,7 +583,10 @@ 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) } @@ -600,13 +597,15 @@ open class MainActivity : AppCompatActivity(), 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() { @@ -615,16 +614,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() } 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 @@ -640,14 +641,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. @@ -668,14 +669,8 @@ 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() } } } @@ -689,7 +684,8 @@ open class MainActivity : AppCompatActivity(), when (keyCode) { KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, - KeyEvent.KEYCODE_CAMERA -> { + KeyEvent.KEYCODE_CAMERA, + -> { captureButton.performClick() } KeyEvent.KEYCODE_FOCUS -> { @@ -717,7 +713,7 @@ 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 @@ -760,7 +756,7 @@ open class MainActivity : AppCompatActivity(), // 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. @@ -777,36 +773,21 @@ open class MainActivity : AppCompatActivity(), 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) - val sessionHandler = ViewfinderEffectHandler(this) camConfig = camConfigFactory.create( environment = sessionHandler, effects = sessionHandler, ) cameraControl = CameraControl(camConfig) - mainOverlay = binding.mainOverlay 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 tabLayout.setOnTouchListener { _, motionEvent -> if (motionEvent.action == MotionEvent.ACTION_UP) { @@ -818,7 +799,6 @@ open class MainActivity : AppCompatActivity(), return@setOnTouchListener false } - timerView = binding.timer previewView.previewStreamState.observe(this) { state: StreamState -> if (state == StreamState.STREAMING) { hidePreviewTransition() @@ -829,9 +809,6 @@ open class MainActivity : AppCompatActivity(), showPreviewTransition() } } - flipCameraCircle = binding.flipCameraCircle - - flipCamIcon = binding.flipCameraIconContent var tapDownTimestamp: Long = 0 flipCameraCircle.setOnTouchListener { _, event -> @@ -892,8 +869,8 @@ open class MainActivity : AppCompatActivity(), it.startAnimation(rotate) camConfig.toggleCameraSelector() } - thirdCircle = binding.thirdCircle - thirdCircle.setOnClickListener { + + binding.thirdCircle.setOnClickListener { resetAutoSleep() if (videoCapturer.isRecording) { imageCapturer.takePicture() @@ -903,18 +880,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() @@ -948,75 +923,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 (!camConfig.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) + 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) - } - - 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( @@ -1034,8 +988,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, @@ -1051,7 +1005,7 @@ open class MainActivity : AppCompatActivity(), it.setMargins( it.leftMargin, (16 * resources.displayMetrics.density.toInt()) + - insets.top, + insets.top, it.rightMargin, it.bottomMargin, ) @@ -1075,23 +1029,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 { @@ -1102,42 +1048,25 @@ open class MainActivity : AppCompatActivity(), } ) - moreOptionsToggle = binding.moreOptions - moreOptionsToggle.setOnClickListener { + binding.moreOptions.setOnClickListener { camConfig.showMoreOptionsForQR() } - 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() settingsDialog.loadInitialState() - gCircle = binding.gCircle - gAngleTextView = binding.gCircleText - - gLineX = binding.gCircleLineX - gLineZ = binding.gCircleLineZ - - gLeftDash = binding.gCircleLeftDash - gRightDash = binding.gCircleRightDash - - gCircleFrame = binding.gCircleFrame - - muteToggle = binding.muteToggle muteToggle.setOnClickListener { if (videoCapturer.isMuted) { videoCapturer.unmuteRecording() @@ -1152,55 +1081,50 @@ 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. @@ -1306,7 +1230,6 @@ open class MainActivity : AppCompatActivity(), private var isQRDialogShowing = false fun onScanResultSuccess(rawText: String) { - if (isQRDialogShowing) return isQRDialogShowing = true @@ -1356,13 +1279,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 { @@ -1402,279 +1329,6 @@ open class MainActivity : AppCompatActivity(), } } - 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) - } - - 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 - } - - override fun onDoubleTapEvent(p0: MotionEvent): Boolean { - return false - } - fun showMessage(@StringRes message: Int) { showMessage(getString(message)) } @@ -1694,8 +1348,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) } /** @@ -1709,13 +1363,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) } /** @@ -1748,9 +1402,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)) @@ -1772,15 +1426,6 @@ open class MainActivity : AppCompatActivity(), rootView.post { sensorNotifier?.notifyListeners() } } - private fun pauseOrientationSensor() { - SensorOrientationChangeNotifier - .getInstance(this)?.remove(this) - } - - private fun resumeOrientationSensor() { - sensorNotifier?.addListener(this) - } - fun forceUpdateOrientationSensor() { sensorNotifier?.notifyListeners(true) } @@ -1792,8 +1437,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 @@ -1807,115 +1454,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)) } @@ -1958,12 +1501,13 @@ open class MainActivity : AppCompatActivity(), } 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) @@ -1978,23 +1522,15 @@ open class MainActivity : AppCompatActivity(), } it.setOnDismissListener { - if (ContextCompat.checkSelfPermission( - this, Manifest.permission.ACCESS_COARSE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) { + if (!hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { camConfig.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 -> { @@ -2085,4 +1621,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..9a1cdd704 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 @@ -16,23 +16,44 @@ 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 settings: CameraSettings = CameraSettings() + + private var storageLocation: String = CapturedItemRepository.MEDIA_STORE_LOCATION private lateinit var binding: MoreSettingsBinding + private lateinit var snackBar: Snackbar private lateinit var sLField: EditText @@ -55,33 +76,44 @@ 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) { + settings = 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) + settings = runBlocking { settingsRepository.settings.first() } + storageLocation = runBlocking { capturedItemRepository.storageLocation.first() } binding = MoreSettingsBinding.inflate(layoutInflater) setContentView(binding.root) @@ -90,37 +122,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 +162,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 +177,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 +186,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 +196,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 +219,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 @@ -223,13 +255,13 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } 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 +271,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 +304,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) { @@ -312,7 +343,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 +351,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 +365,9 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { clearFocus() dumpData() true - } else false + } else { + false + } } fun showMessage(msg: String) { @@ -349,36 +381,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.camConfig.isInCaptureMode) + it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.camConfig.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/viewfinder/ViewfinderEffectHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt index 904e9010c..9237fab44 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt @@ -13,13 +13,13 @@ import androidx.camera.core.Preview import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import app.grapheneos.camera.App -import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment -import app.grapheneos.camera.ui.activities.MainActivity import app.grapheneos.camera.R import app.grapheneos.camera.TunePlayer import app.grapheneos.camera.analyzer.QRAnalyzer +import app.grapheneos.camera.data.camera.repository.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.showQrFormatsDialog import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog import com.google.zxing.BarcodeFormat @@ -27,7 +27,8 @@ import java.util.concurrent.Executor internal class ViewfinderEffectHandler( private val activity: MainActivity, -) : CameraSessionEnvironment, ViewfinderEffects { +) : CameraSessionEnvironment, + ViewfinderEffects { override val sessionContext: Context get() { @@ -58,14 +59,13 @@ internal class ViewfinderEffectHandler( get() { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - activity.display?.rotation ?: @Suppress("DEPRECATION") - activity.windowManager.defaultDisplay.rotation + 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 -> @Suppress("DEPRECATION") activity.windowManager.defaultDisplay.rotation + else -> deprecatedDisplayRotation() } } @@ -151,7 +151,7 @@ internal class ViewfinderEffectHandler( override fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) { // Focus camera on touch/tap - activity.previewView.setOnTouchListener(activity) + activity.previewView.setOnTouchListener(activity.gestureHandler) activity.previewView.applyPreviewRatio(aspectRatio, cameraInfo) } @@ -328,6 +328,11 @@ internal class ViewfinderEffectHandler( 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/ViewfinderGestureHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt new file mode 100644 index 000000000..8d35091a4 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -0,0 +1,247 @@ +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 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.camConfig.isQRMode) { + return false + } + + val x = event.x + val y = event.y + + val camera = activity.camConfig.camera ?: return true + + val autoFocusPoint = activity.previewView.meteringPointFactory.createPoint(x, y) + activity.animateFocusRing(x, y) + + val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint) + + if (!activity.camConfig.isVideoMode) { + activity.camConfig.mPlayer.playFocusStartSound() + } + + if (activity.camConfig.focusTimeout == 0L) { + focusBuilder.disableAutoCancel() + } else { + focusBuilder.setAutoCancelDuration( + activity.camConfig.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.camConfig.zoomState + var scale = 1f + + if (zoomState != null) { + scale = zoomState.zoomRatio * detector.scaleFactor + } + + val camera = activity.camConfig.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.camConfig.isQRMode -> { + if (activity.settingsIcon.isEnabled) { + activity.settingsIcon.performClick() + } + } + + !activity.camConfig.scanAllCodes -> { + activity.camConfig.showMoreOptionsForQR() + } + } + } + + 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..fbad7d46c --- /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 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 + } + + activity.camConfig.imageCapture?.targetRotation = tr + activity.camConfig.videoCapture?.targetRotation = tr + activity.camConfig.iAnalyzer?.targetRotation = tr + + 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.camConfig.isQRMode || activity.camConfig.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/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/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 index 952e0cf38..a7060864b 100644 --- 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 @@ -17,7 +17,6 @@ class ResolveAvailableModesImplTest { private val store = ExtensionAvailabilityStoreImpl() - @Test fun invoke_aColdCache_offersOnlyTheModesThatNeedNoExtension() { assertEquals( @@ -39,7 +38,9 @@ class ResolveAvailableModesImplTest { fun invoke_anExtensionUsableOnOneLens_offersThatMode() { store.record(NIGHT_BACK, usable = true) - assertTrue(CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true)) + assertTrue( + CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) + ) } @Test @@ -47,7 +48,9 @@ class ResolveAvailableModesImplTest { store.record(NIGHT_FRONT, usable = false) store.record(NIGHT_BACK, usable = false) - assertFalse(CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true)) + assertFalse( + CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) + ) } @Test 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) } + } +} From 1bdab843bcd18c25a151f1d8f79dfd297ba8c78b Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Mon, 7 Sep 2026 21:43:50 +0200 Subject: [PATCH 15/23] Clean up after decomposing CamConfig --- app/detekt-baseline-debug.xml | 2 - .../java/app/grapheneos/camera/CamConfig.kt | 22 +- .../camera/data/camera/model/BindOutcome.kt | 7 + .../camera/data/camera/model/ExtensionKey.kt | 20 +- .../camera/repository/CameraProviderSource.kt | 47 +++-- .../ExtensionAvailabilityRepository.kt} | 22 +- .../{repository => session}/CameraSession.kt | 188 +++++++++--------- .../CameraSessionEnvironment.kt | 2 +- .../FeatureCombinationSupport.kt | 2 +- .../data/media/store/MediaPrefsMigration.kt | 13 +- .../camera/di/camera/CameraBindsModule.kt | 14 +- .../camera/usecase/BuildCameraSessionPlan.kt | 4 +- .../camera/usecase/ResolveAvailableModes.kt | 21 +- .../camera/ui/activities/MainActivity.kt | 102 +++++----- .../camera/ui/activities/MoreSettings.kt | 3 +- .../ui/viewfinder/ViewfinderEffectHandler.kt | 2 +- .../camera/ui/viewfinder/ViewfinderEffects.kt | 2 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 1 + .../ViewfinderOrientationHandler.kt | 8 +- ...xtensionAvailabilityRepositoryImplTest.kt} | 50 ++--- .../camera/data/core/LegacyPreferences.kt | 12 -- .../data/media/MediaPrefsMigrationTest.kt | 36 +--- .../usecase/ResolveAvailableModesImplTest.kt | 16 +- 23 files changed, 270 insertions(+), 326 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/model/BindOutcome.kt rename app/src/main/java/app/grapheneos/camera/data/camera/{store/ExtensionAvailabilityStore.kt => repository/ExtensionAvailabilityRepository.kt} (79%) rename app/src/main/java/app/grapheneos/camera/data/camera/{repository => session}/CameraSession.kt (94%) rename app/src/main/java/app/grapheneos/camera/data/camera/{repository => session}/CameraSessionEnvironment.kt (92%) rename app/src/main/java/app/grapheneos/camera/data/camera/{repository => session}/FeatureCombinationSupport.kt (98%) rename app/src/test/java/app/grapheneos/camera/data/camera/{ExtensionAvailabilityStoreImplTest.kt => ExtensionAvailabilityRepositoryImplTest.kt} (58%) diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 582f6061c..a17d6dcdb 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -150,7 +150,6 @@ ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$override fun onTouch: Boolean ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeLeft ReturnCount:ViewfinderGestureHandler.kt:ViewfinderGestureHandler$private fun onSwipeRight - SwallowedException:CameraSession.kt:CameraSession$e: IllegalArgumentException SwallowedException:CaptureActivity.kt:CaptureActivity$e: Exception SwallowedException:CapturedItems.kt:e: ActivityNotFoundException SwallowedException:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception @@ -160,7 +159,6 @@ SwallowedException:VideoCapturer.kt:VideoCapturer$e: Exception SwallowedException:VideoCapturer.kt:VideoCapturer$exception: Exception ThrowsCount:ImageSaver.kt:ImageSaver$@Throws(ImageSaverException::class) private fun saveImageInner - TooGenericExceptionCaught:CameraSession.kt:CameraSession$e: Exception TooGenericExceptionCaught:CameraSession.kt:CameraSession$exception: RuntimeException TooGenericExceptionCaught:CaptureActivity.kt:CaptureActivity$e: Exception TooGenericExceptionCaught:GallerySliderAdapter.kt:GallerySliderAdapter$e: Exception diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index cbb7f0a06..429f740de 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -17,9 +17,10 @@ import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.Quality import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture +import app.grapheneos.camera.data.camera.model.BindOutcome import app.grapheneos.camera.data.camera.model.CameraBindSettings -import app.grapheneos.camera.data.camera.repository.CameraSession -import app.grapheneos.camera.data.camera.repository.CameraSessionEnvironment +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.media.repository.CapturedItemRepository import app.grapheneos.camera.data.settings.model.CameraSettings @@ -64,7 +65,7 @@ class CamConfig @AssistedInject constructor( environment = environment, listener = object : CameraSession.Listener { override fun onZoomStateChanged() { - effects.updateZoomThumb() + effects.updateZoomThumb(shouldShowPanel = true) } override fun onCameraProviderUnavailable() { @@ -479,10 +480,9 @@ class CamConfig @AssistedInject constructor( } fun toggleAspectRatio() { - aspectRatio = if (aspectRatio == AspectRatio.RATIO_16_9) { - AspectRatio.RATIO_4_3 - } else { - AspectRatio.RATIO_16_9 + aspectRatio = when (aspectRatio) { + AspectRatio.RATIO_16_9 -> AspectRatio.RATIO_4_3 + else -> AspectRatio.RATIO_16_9 } startCamera(true) } @@ -613,9 +613,9 @@ class CamConfig @AssistedInject constructor( ) return when (session.bind(bindSettings)) { - CameraSession.BindOutcome.FAILED -> effects.showMessage(R.string.bind_failure) + BindOutcome.FAILED -> effects.showMessage(R.string.bind_failure) - CameraSession.BindOutcome.EXTENSION_UNUSABLE -> { + BindOutcome.EXTENSION_UNUSABLE -> { effects.showMessage(R.string.extension_mode_unavailable) // The bind never completed: currentMode still names the mode that was just @@ -627,7 +627,7 @@ class CamConfig @AssistedInject constructor( switchMode(DEFAULT_CAMERA_MODE) } - CameraSession.BindOutcome.BOUND -> announceBind() + BindOutcome.BOUND -> announceBind() } } @@ -650,7 +650,7 @@ class CamConfig @AssistedInject constructor( session.reattachZoomState() - effects.updateZoomThumb(false) + effects.updateZoomThumb(shouldShowPanel = false) camera?.cameraInfo?.exposureState?.let { effects.applyExposureState(it) } 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/ExtensionKey.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/ExtensionKey.kt index 465c4a9a0..07ec24b2b 100644 --- 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 @@ -1,6 +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/repository/CameraProviderSource.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraProviderSource.kt index 831c1e23d..d89d5bbec 100644 --- 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 @@ -11,7 +11,10 @@ import kotlin.concurrent.thread interface CameraProviderSource { - fun acquireProvider(context: Context, onResult: (ProcessCameraProvider?) -> Unit) + fun acquireProvider( + context: Context, + onResult: (ProcessCameraProvider?) -> Unit, + ) fun acquireExtensionsManager( context: Context, @@ -27,18 +30,19 @@ internal class CameraProviderSourceImpl @Inject constructor() : CameraProviderSo 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 + } - providerFuture.addListener( - { - val provider = try { - providerFuture.get() - } catch (exception: ExecutionException) { - Log.e(TAG, "Camera provider initialization failed", exception) - null - } + onResult(provider) + } - onResult(provider) - }, + providerFuture.addListener( + deliverProvider, ContextCompat.getMainExecutor(context), ) } @@ -58,18 +62,19 @@ internal class CameraProviderSourceImpl @Inject constructor() : CameraProviderSo 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 + } - extensionsManagerFuture.addListener( - { - val extensionsManager = try { - extensionsManagerFuture.get() - } catch (exception: ExecutionException) { - Log.e(TAG, "Extensions manager future failed", exception) - null - } + onResult(extensionsManager) + } - onResult(extensionsManager) - }, + extensionsManagerFuture.addListener( + deliverExtensionsManager, ContextCompat.getMainExecutor(context), ) } catch (exception: Exception) { diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt b/app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt similarity index 79% rename from app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt index ffd3b7aa0..37de4e863 100644 --- a/app/src/main/java/app/grapheneos/camera/data/camera/store/ExtensionAvailabilityStore.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/repository/ExtensionAvailabilityRepository.kt @@ -1,12 +1,11 @@ -package app.grapheneos.camera.data.camera.store +package app.grapheneos.camera.data.camera.repository -import androidx.camera.core.CameraSelector 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 ExtensionAvailabilityStore { +interface ExtensionAvailabilityRepository { fun verdict(key: ExtensionKey): Boolean? @@ -22,7 +21,8 @@ interface ExtensionAvailabilityStore { fun clear() } -internal class ExtensionAvailabilityStoreImpl @Inject constructor() : ExtensionAvailabilityStore { +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 @@ -56,12 +56,7 @@ internal class ExtensionAvailabilityStoreImpl @Inject constructor() : ExtensionA for (mode in CameraMode.entries) { if (mode.extensionMode == ExtensionMode.NONE) continue - for (lensFacing in LENS_FACINGS) { - val key = ExtensionKey( - lensFacing = lensFacing, - extensionMode = mode.extensionMode, - ) - + for (key in ExtensionKey.onBothLenses(mode.extensionMode)) { if (usability[key] == null) { result.add(key) } @@ -74,11 +69,4 @@ internal class ExtensionAvailabilityStoreImpl @Inject constructor() : ExtensionA override fun clear() { usability.clear() } - - private companion object { - val LENS_FACINGS = listOf( - CameraSelector.LENS_FACING_FRONT, - CameraSelector.LENS_FACING_BACK, - ) - } } diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt similarity index 94% rename from app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt index 77a5d1516..ce0c9d4e5 100644 --- a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSession.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.data.camera.repository +package app.grapheneos.camera.data.camera.session import android.annotation.SuppressLint import android.os.Handler @@ -29,9 +29,11 @@ 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.model.BindOutcome import app.grapheneos.camera.data.camera.model.CameraBindSettings import app.grapheneos.camera.data.camera.model.ExtensionKey -import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore +import app.grapheneos.camera.data.camera.repository.CameraProviderSource +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.model.CameraBindRequest import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest @@ -50,55 +52,28 @@ class CameraSession @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, @Assisted private val listener: Listener, private val cameraProviderSource: CameraProviderSource, - private val extensionAvailabilityStore: ExtensionAvailabilityStore, + private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, private val featureCombinationSupport: FeatureCombinationSupport, private val buildCameraSessionPlan: BuildCameraSessionPlan, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, ) { - interface Listener { - - fun onZoomStateChanged() - - fun onCameraProviderUnavailable() - - fun onExtensionsUnavailable() - - fun onProviderReady(forced: Boolean) + var camera: Camera? = null - fun onFeaturesSelected( - boundLensFacing: Int, - requested: List, - qualityFeature: GroupableFeature?, - selected: Set, - ) - } + var imageCapture: ImageCapture? = null - @AssistedFactory - interface Factory { + var preview: Preview? = null - fun create( - environment: CameraSessionEnvironment, - listener: Listener, - ): CameraSession - } + var videoCapture: VideoCapture? = null - private data class SnapshotProbeKey( - val lensFacing: Int, - val videoQuality: Quality, - val usesFeatureGroup: Boolean, - val captureMode: ImageCaptureMode, - val selectHighestResolution: Boolean, - ) + var iAnalyzer: ImageAnalysis? = null - enum class BindOutcome { - BOUND, - FAILED, - EXTENSION_UNUSABLE, - } + var lensFacing = DEFAULT_LENS_FACING - var camera: Camera? = null + 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 @@ -133,55 +108,44 @@ class CameraSession @AssistedInject constructor( private var extensionsManager: ExtensionsManager? = null - var imageCapture: ImageCapture? = null + private var extensionProbesInFlight = false - var preview: Preview? = null + private var qrAnalyzer: QRAnalyzer? = null private val cameraExecutor by lazy { Executors.newSingleThreadExecutor() } - var videoCapture: VideoCapture? = null - - private var qrAnalyzer: QRAnalyzer? = null - val extensionsAvailable: Boolean get() { return extensionsManager != null && cameraProvider != null } - var iAnalyzer: ImageAnalysis? = null - val isFlashAvailable: Boolean - get() = camera?.cameraInfo?.hasFlashUnit() ?: false + get() { + return camera?.cameraInfo?.hasFlashUnit() ?: false + } + + val isZslSupported: Boolean + get() { + return camera?.cameraInfo?.isZslSupported ?: 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 + field = when { + isFlashAvailable -> { + camera?.cameraControl?.enableTorch(value) + value + } + + else -> false } } - var lensFacing = DEFAULT_LENS_FACING - - private var cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(DEFAULT_LENS_FACING) - .build() - - val isZslSupported: Boolean by lazy { - requireNotNull(camera) { - "Queried before the first bind" - }.cameraInfo.isZslSupported - } - - private var extensionProbesInFlight = false - fun refreshQrHints() { qrAnalyzer?.refreshHints() } @@ -255,7 +219,6 @@ class CameraSession @AssistedInject constructor( cameraProviderSource.acquireProvider(environment.sessionContext) { provider -> when (provider) { null -> listener.onCameraProviderUnavailable() - else -> onCameraProviderReady(provider, forced, extensionMode) } } @@ -270,7 +233,7 @@ class CameraSession @AssistedInject constructor( // 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. - extensionAvailabilityStore.clear() + extensionAvailabilityRepository.clear() snapshotSupport.clear() probedCameraProvider = provider } @@ -279,10 +242,9 @@ class CameraSession @AssistedInject constructor( // Manually switch to the other lens facing (if the default lens facing isn't // supported for the current device) if (!isLensFacingSupported(lensFacing, extensionMode)) { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK + lensFacing = when (lensFacing) { + CameraSelector.LENS_FACING_BACK -> CameraSelector.LENS_FACING_FRONT + else -> CameraSelector.LENS_FACING_BACK } } @@ -290,10 +252,9 @@ class CameraSession @AssistedInject constructor( environment.sessionContext, provider, ) { manager -> - if (manager == null) { - listener.onExtensionsUnavailable() - } else { - extensionsManager = manager + when (manager) { + null -> listener.onExtensionsUnavailable() + else -> extensionsManager = manager } listener.onProviderReady(forced) @@ -310,7 +271,7 @@ class CameraSession @AssistedInject constructor( // 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 (ExtensionAvailabilityStore) because every step of + // 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 @@ -321,8 +282,8 @@ class CameraSession @AssistedInject constructor( // 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 ExtensionAvailabilityStore). The provider and manager are parameters + // 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. @@ -371,12 +332,13 @@ class CameraSession @AssistedInject constructor( return@execute } - extensionAvailabilityStore.recordProbeRound(verdicts) + extensionAvailabilityRepository.recordProbeRound(verdicts) onSettled() } } } + @Suppress("TooGenericExceptionCaught") private fun probeExtension( provider: ProcessCameraProvider, em: ExtensionsManager, @@ -402,18 +364,18 @@ class CameraSession @AssistedInject constructor( true } } - } catch (e: UnsupportedOperationException) { + } 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", e) + Log.w(TAG, "Extension mode $extensionMode is advertised but unusable here", exception) false - } catch (e: Exception) { + } 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", e) + Log.w(TAG, "Probing extension mode $extensionMode failed, will retry later", exception) null } } @@ -433,7 +395,7 @@ class CameraSession @AssistedInject constructor( lensFacing = lensFacing, extensionMode = extensionMode, ) - extensionAvailabilityStore.verdict(key)?.let { return it } + extensionAvailabilityRepository.verdict(key)?.let { return it } if (!probeOnMiss) return false @@ -446,7 +408,7 @@ class CameraSession @AssistedInject constructor( if (extensionProbesInFlight) return false val verdict = probeExtension(provider, em, selector, extensionMode) ?: return false - extensionAvailabilityStore.record(key, usable = verdict) + extensionAvailabilityRepository.record(key, usable = verdict) return verdict } @@ -522,7 +484,7 @@ class CameraSession @AssistedInject constructor( tCameraSelector, extensionMode, ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { return false } } @@ -765,7 +727,7 @@ class CameraSession @AssistedInject constructor( } Log.e(TAG, "Extension mode $extMode failed to bind; disabling it", exception) - extensionAvailabilityStore.record(key, usable = false) + extensionAvailabilityRepository.record(key, usable = false) return BindOutcome.EXTENSION_UNUSABLE } @@ -776,7 +738,7 @@ class CameraSession @AssistedInject constructor( private fun unprobedExtensions(): List { if (extensionsManager == null || cameraProvider == null) return emptyList() - return extensionAvailabilityStore.unprobed() + return extensionAvailabilityRepository.unprobed() } private fun selectorFor(lensFacing: Int): CameraSelector { @@ -786,16 +748,50 @@ class CameraSession @AssistedInject constructor( } } + interface Listener { + + fun onZoomStateChanged() + + fun onCameraProviderUnavailable() + + fun onExtensionsUnavailable() + + fun onProviderReady(forced: Boolean) + + fun onFeaturesSelected( + boundLensFacing: Int, + requested: List, + qualityFeature: GroupableFeature?, + selected: Set, + ) + } + + @AssistedFactory + interface Factory { + fun create( + environment: CameraSessionEnvironment, + listener: Listener, + ): CameraSession + } + + private data class SnapshotProbeKey( + val lensFacing: Int, + val videoQuality: Quality, + val usesFeatureGroup: Boolean, + val captureMode: ImageCaptureMode, + val selectHighestResolution: Boolean, + ) + companion object { private const val TAG = "CameraSession" const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK - val FRONT_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + private val FRONT_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_FRONT) .build() - val REAR_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() + private val REAR_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_BACK) .build() @@ -805,6 +801,10 @@ class CameraSession @AssistedInject constructor( // snapshots away for no reason or keeps them on a camera that cannot bind them. private val snapshotSupport = 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 probedCameraProvider: ProcessCameraProvider? = null + // 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 @@ -816,9 +816,5 @@ class CameraSession @AssistedInject constructor( snapshotSupport.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 } } diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt similarity index 92% rename from app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt index 5df457b3d..7fcde602c 100644 --- a/app/src/main/java/app/grapheneos/camera/data/camera/repository/CameraSessionEnvironment.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionEnvironment.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.data.camera.repository +package app.grapheneos.camera.data.camera.session import android.content.Context import androidx.camera.core.Preview diff --git a/app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt similarity index 98% rename from app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt index df902eabc..4ef0d9dca 100644 --- a/app/src/main/java/app/grapheneos/camera/data/camera/repository/FeatureCombinationSupport.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/FeatureCombinationSupport.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.data.camera.repository +package app.grapheneos.camera.data.camera.session import android.hardware.camera2.CameraCharacteristics import android.os.Build 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/di/camera/CameraBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt index 85687a917..004150f09 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -2,10 +2,10 @@ package app.grapheneos.camera.di.camera import app.grapheneos.camera.data.camera.repository.CameraProviderSource import app.grapheneos.camera.data.camera.repository.CameraProviderSourceImpl -import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupport -import app.grapheneos.camera.data.camera.repository.FeatureCombinationSupportImpl -import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStore -import app.grapheneos.camera.data.camera.store.ExtensionAvailabilityStoreImpl +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepositoryImpl +import app.grapheneos.camera.data.camera.session.FeatureCombinationSupport +import app.grapheneos.camera.data.camera.session.FeatureCombinationSupportImpl import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan @@ -27,9 +27,9 @@ internal abstract class CameraBindsModule { @Binds @Singleton - abstract fun bindExtensionAvailabilityStore( - impl: ExtensionAvailabilityStoreImpl, - ): ExtensionAvailabilityStore + abstract fun bindExtensionAvailabilityRepository( + impl: ExtensionAvailabilityRepositoryImpl, + ): ExtensionAvailabilityRepository @Binds @Reusable diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt index 85d1fd3b4..936c8d871 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt +++ b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt @@ -136,7 +136,9 @@ internal class BuildCameraSessionPlanImpl @Inject constructor() : BuildCameraSes val previewBuilder = Preview.Builder() .setTargetRotation(request.previewTargetRotation) .setResolutionSelector( - ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build() + ResolutionSelector.Builder() + .setAspectRatioStrategy(aspectRatioStrategy) + .build() ) // Pixels and potentially other devices enable EIS by default, which reduces the field of 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 index 2e3f88641..0dd03a988 100644 --- 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 @@ -1,9 +1,8 @@ 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.store.ExtensionAvailabilityStore +import app.grapheneos.camera.data.camera.repository.ExtensionAvailabilityRepository import app.grapheneos.camera.data.core.model.CameraMode import javax.inject.Inject @@ -20,7 +19,7 @@ interface ResolveAvailableModes { } internal class ResolveAvailableModesImpl @Inject constructor( - private val extensionAvailabilityStore: ExtensionAvailabilityStore, + private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, ) : ResolveAvailableModes { override fun invoke( @@ -40,20 +39,8 @@ internal class ResolveAvailableModesImpl @Inject constructor( } private fun isUsableOnEitherLens(extensionMode: Int): Boolean { - return LENS_FACINGS.any { lensFacing -> - val key = ExtensionKey( - lensFacing = lensFacing, - extensionMode = extensionMode, - ) - - extensionAvailabilityStore.verdict(key) == true + return ExtensionKey.onBothLenses(extensionMode).any { key -> + extensionAvailabilityRepository.verdict(key) == true } } - - private companion object { - private val LENS_FACINGS = listOf( - CameraSelector.LENS_FACING_FRONT, - CameraSelector.LENS_FACING_BACK, - ) - } } 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 89e0a07ee..aabca1f21 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 @@ -136,48 +136,24 @@ open class MainActivity : AppCompatActivity() { internal val gestureHandler by lazy { ViewfinderGestureHandler(this) } internal val orientationHandler by lazy { ViewfinderOrientationHandler(this) } - fun onDeviceAngleChange(xDegrees: Float, zDegrees: Float) { - orientationHandler.onDeviceAngleChange(xDegrees, zDegrees) - } - val gestureDetector: GestureDetector get() = gestureHandler.gestureDetector + lateinit var imageCapturer: ImageCapturer + + lateinit var videoCapturer: VideoCapturer + + lateinit var settingsDialog: SettingsDialog + val threeButtons: View get() = binding.threeButtons - private val cameraPermission = arrayOf(Manifest.permission.CAMERA) - val previewView: PreviewView get() = binding.preview val bottomOverlay: View get() = binding.bottomOverlay - // 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 - val rootView: View get() = binding.root @@ -196,10 +172,6 @@ open class MainActivity : AppCompatActivity() { val azToggle: QRToggle get() = binding.aztecToggle - lateinit var imageCapturer: ImageCapturer - - lateinit var videoCapturer: VideoCapturer - val flipCameraCircle: View get() = binding.flipCameraCircle @@ -245,16 +217,12 @@ open class MainActivity : AppCompatActivity() { val mainOverlay: ImageView get() = binding.mainOverlay - lateinit var settingsDialog: SettingsDialog - val previewGrid: CustomGrid get() = binding.previewGrid val cdTimer: CountDownTimerUI get() = binding.cTimer - var timerDuration = 0 - val cbText: TextView get() = binding.captureButtonText @@ -267,8 +235,41 @@ open class MainActivity : AppCompatActivity() { val muteToggle: ShapeableImageView get() = binding.muteToggle + 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 { @@ -300,19 +301,6 @@ open class MainActivity : AppCompatActivity() { binding.focusRing.visibility = View.INVISIBLE } - val micOffIcon: ImageView - get() = binding.micOff - - private var shouldRestartRecording = false - - fun startFocusTimer() { - handler.postDelayed(runnable, autoCenterFocusDuration) - } - - fun cancelFocusTimer() { - handler.removeCallbacks(runnable) - } - private val restartRecordingWithAudioPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> @@ -347,6 +335,18 @@ open class MainActivity : AppCompatActivity() { } } + fun onDeviceAngleChange(xDegrees: Float, zDegrees: Float) { + orientationHandler.onDeviceAngleChange(xDegrees, zDegrees) + } + + fun startFocusTimer() { + handler.postDelayed(runnable, autoCenterFocusDuration) + } + + fun cancelFocusTimer() { + handler.removeCallbacks(runnable) + } + private fun showAudioPermissionDeniedDialog(onDisableAudio: () -> Unit = {}) { val builder = MaterialAlertDialogBuilder(this) .setTitle(R.string.audio_permission_dialog_title) 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 9a1cdd704..c94441020 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 @@ -250,7 +249,7 @@ open class MoreSettings : 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) } diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt index 9237fab44..83a378c2e 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt @@ -16,7 +16,7 @@ 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.repository.CameraSessionEnvironment +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 diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt index 8d742968c..3afd84433 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt @@ -26,7 +26,7 @@ interface ViewfinderEffects { fun flashPreview(selfIlluminate: Boolean) - fun updateZoomThumb(shouldShowPanel: Boolean = true) + fun updateZoomThumb(shouldShowPanel: Boolean) fun applyExposureState(exposureState: ExposureState) 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 index 8d35091a4..d8d4dacb0 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -200,6 +200,7 @@ internal class ViewfinderGestureHandler( val i = activity.tabLayout.selectedTabPosition - 1 Log.i(TAG, "onSwipeRight $i") + activity.tabLayout.getTabAt(i)?.let { activity.finalizeMode(it) } 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 index fbad7d46c..6017d0704 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt @@ -54,16 +54,16 @@ internal class ViewfinderOrientationHandler( @SuppressLint("RestrictedApi") override fun onOrientationChange(orientation: Int) { - val tr = when (orientation) { + 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.camConfig.imageCapture?.targetRotation = tr - activity.camConfig.videoCapture?.targetRotation = tr - activity.camConfig.iAnalyzer?.targetRotation = tr + activity.camConfig.imageCapture?.targetRotation = targetRotation + activity.camConfig.videoCapture?.targetRotation = targetRotation + activity.camConfig.iAnalyzer?.targetRotation = targetRotation if (activity.videoCapturer.isRecording) return diff --git a/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt similarity index 58% rename from app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt rename to app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt index 82a0b40e1..a9223fd4e 100644 --- a/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityStoreImplTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/camera/ExtensionAvailabilityRepositoryImplTest.kt @@ -3,7 +3,7 @@ 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.store.ExtensionAvailabilityStoreImpl +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 @@ -14,22 +14,22 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class ExtensionAvailabilityStoreImplTest { +class ExtensionAvailabilityRepositoryImplTest { - private val store = ExtensionAvailabilityStoreImpl() + private val repository = ExtensionAvailabilityRepositoryImpl() @Test fun verdict_anUnprobedKey_isUnknown() { - assertNull(store.verdict(NIGHT_FRONT)) + assertNull(repository.verdict(NIGHT_FRONT)) } @Test fun record_aVerdict_readsBack() { - store.record(NIGHT_FRONT, usable = true) - store.record(NIGHT_BACK, usable = false) + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = false) - assertTrue(store.verdict(NIGHT_FRONT) == true) - assertFalse(store.verdict(NIGHT_BACK) == true) + assertTrue(repository.verdict(NIGHT_FRONT) == true) + assertFalse(repository.verdict(NIGHT_BACK) == true) } @Test @@ -39,7 +39,7 @@ class ExtensionAvailabilityStoreImplTest { .filter { it != ExtensionMode.NONE } .toSet() - val unprobed = store.unprobed() + val unprobed = repository.unprobed() assertEquals(extensionModes.size * 2, unprobed.size) assertEquals(extensionModes, unprobed.map { it.extensionMode }.toSet()) @@ -51,48 +51,48 @@ class ExtensionAvailabilityStoreImplTest { @Test fun unprobed_aModeWithoutAnExtension_isNeverListed() { - assertTrue(store.unprobed().none { it.extensionMode == ExtensionMode.NONE }) + assertTrue(repository.unprobed().none { it.extensionMode == ExtensionMode.NONE }) } @Test fun unprobed_anAnsweredKey_dropsOutOfTheList() { - store.record(NIGHT_FRONT, usable = false) + repository.record(NIGHT_FRONT, usable = false) - assertFalse(NIGHT_FRONT in store.unprobed()) - assertTrue(NIGHT_BACK in store.unprobed()) + assertFalse(NIGHT_FRONT in repository.unprobed()) + assertTrue(NIGHT_BACK in repository.unprobed()) } @Test fun recordProbeRound_aVerdictForAnUnprobedKey_isRemembered() { - store.recordProbeRound(mapOf(NIGHT_FRONT to true)) + repository.recordProbeRound(mapOf(NIGHT_FRONT to true)) - assertTrue(store.verdict(NIGHT_FRONT) == true) + assertTrue(repository.verdict(NIGHT_FRONT) == true) } @Test fun recordProbeRound_aTransientFailure_isNotRemembered() { - store.recordProbeRound(mapOf(NIGHT_FRONT to null)) + repository.recordProbeRound(mapOf(NIGHT_FRONT to null)) - assertNull(store.verdict(NIGHT_FRONT)) + assertNull(repository.verdict(NIGHT_FRONT)) } @Test fun recordProbeRound_aKeyBlacklistedWhileTheRoundWasInFlight_staysBlacklisted() { - store.record(NIGHT_FRONT, usable = false) - store.recordProbeRound(mapOf(NIGHT_FRONT to true)) + repository.record(NIGHT_FRONT, usable = false) + repository.recordProbeRound(mapOf(NIGHT_FRONT to true)) - assertFalse(store.verdict(NIGHT_FRONT) == true) + assertFalse(repository.verdict(NIGHT_FRONT) == true) } @Test fun clear_aWarmCache_forgetsEveryVerdict() { - store.record(NIGHT_FRONT, usable = true) - store.record(NIGHT_BACK, usable = false) + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = false) - store.clear() + repository.clear() - assertNull(store.verdict(NIGHT_FRONT)) - assertNull(store.verdict(NIGHT_BACK)) + assertNull(repository.verdict(NIGHT_FRONT)) + assertNull(repository.verdict(NIGHT_BACK)) } private companion object { 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/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/domain/camera/usecase/ResolveAvailableModesImplTest.kt b/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveAvailableModesImplTest.kt index a7060864b..311d3eff5 100644 --- 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 @@ -3,7 +3,7 @@ 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.store.ExtensionAvailabilityStoreImpl +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 @@ -15,7 +15,7 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class ResolveAvailableModesImplTest { - private val store = ExtensionAvailabilityStoreImpl() + private val repository = ExtensionAvailabilityRepositoryImpl() @Test fun invoke_aColdCache_offersOnlyTheModesThatNeedNoExtension() { @@ -36,7 +36,7 @@ class ResolveAvailableModesImplTest { @Test fun invoke_anExtensionUsableOnOneLens_offersThatMode() { - store.record(NIGHT_BACK, usable = true) + repository.record(NIGHT_BACK, usable = true) assertTrue( CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) @@ -45,8 +45,8 @@ class ResolveAvailableModesImplTest { @Test fun invoke_anExtensionUnusableOnBothLenses_withholdsThatMode() { - store.record(NIGHT_FRONT, usable = false) - store.record(NIGHT_BACK, usable = false) + repository.record(NIGHT_FRONT, usable = false) + repository.record(NIGHT_BACK, usable = false) assertFalse( CameraMode.NIGHT in availableModes(allowsQrScanning = true, extensionsAvailable = true) @@ -55,8 +55,8 @@ class ResolveAvailableModesImplTest { @Test fun invoke_noExtensionsManagerYet_withholdsEveryExtensionModeDespiteAWarmCache() { - store.record(NIGHT_FRONT, usable = true) - store.record(NIGHT_BACK, usable = true) + repository.record(NIGHT_FRONT, usable = true) + repository.record(NIGHT_BACK, usable = true) assertEquals( setOf(CameraMode.QR_SCAN, CameraMode.CAMERA, CameraMode.VIDEO), @@ -68,7 +68,7 @@ class ResolveAvailableModesImplTest { allowsQrScanning: Boolean, extensionsAvailable: Boolean, ): Set { - return ResolveAvailableModesImpl(store).invoke( + return ResolveAvailableModesImpl(repository).invoke( allowsQrScanning = allowsQrScanning, extensionsAvailable = extensionsAvailable, ) From 6a49434d00adc40c4b2af552bbdbca972c81b32c Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Mon, 7 Sep 2026 23:44:56 +0200 Subject: [PATCH 16/23] Reach camera session directly --- app/config/ktlint/baseline.xml | 68 ++--- app/detekt-baseline-debug.xml | 2 +- .../camera/BottomTabLayoutRegressionTest.kt | 2 +- .../camera/CameraModeTabsRegressionTest.kt | 4 +- .../camera/ModeSwitchLatencyRegressionTest.kt | 35 ++- .../java/app/grapheneos/camera/ModeTabs.kt | 2 +- .../camera/PhotoQualityRegressionTest.kt | 4 +- .../camera/SelfTimerRegressionTest.kt | 12 +- .../camera/SettingsDialogRegressionTest.kt | 4 +- .../camera/VideoCapturerRegressionTest.kt | 17 +- .../java/app/grapheneos/camera/CamConfig.kt | 288 +++++++----------- .../camera/capturer/ImageCapturer.kt | 7 +- .../grapheneos/camera/capturer/ImageSaver.kt | 4 +- .../camera/capturer/VideoCapturer.kt | 10 +- .../data/media/store/MediaStoreCollections.kt | 12 + .../di/core/CoroutinesProvidesModule.kt | 6 + .../grapheneos/camera/di/core/Qualifiers.kt | 4 + .../grapheneos/camera/ui/SettingsDialog.kt | 16 +- .../camera/ui/activities/CaptureActivity.kt | 4 +- .../camera/ui/activities/MainActivity.kt | 14 +- .../camera/ui/activities/MoreSettings.kt | 2 +- .../camera/ui/seekbar/ExposureBar.kt | 2 +- .../grapheneos/camera/ui/seekbar/ZoomBar.kt | 4 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 6 +- .../ViewfinderOrientationHandler.kt | 6 +- .../grapheneos/camera/util/CameraControl.kt | 4 +- 26 files changed, 250 insertions(+), 289 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/media/store/MediaStoreCollections.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 6ea38ab4b..e1b743da6 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -70,21 +70,21 @@ - + - - - - - - - - + + + + + + + + @@ -200,29 +200,29 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -531,11 +531,11 @@ - - + + - + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index a17d6dcdb..6a3a2e881 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -177,7 +177,7 @@ UnsafeCallOnNullableType:BlurBitmap.kt:BlurBitmap$sentBitmap.config!! UnsafeCallOnNullableType:BottomTabLayout.kt:BottomTabLayout$getTabAt(it)!! UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! - UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.imageCapture!! + UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.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()!! diff --git a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 6ae24426a..7e464faa7 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt @@ -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.camConfig.session.camera != null } } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index 810575aca..8e7dfb439 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt @@ -131,7 +131,7 @@ class CameraModeTabsRegressionTest { 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.camConfig.session.camera != null } // Flinging before the tabs were built would pass whether or not any get built Thread.sleep(TAB_BUILD_DWELL_MS) @@ -144,7 +144,7 @@ class CameraModeTabsRegressionTest { } private fun assertNoModeTabs(scenario: ActivityScenario) { - waitUntil(scenario, "camera is bound") { it.camConfig.camera != null } + waitUntil(scenario, "camera is bound") { it.camConfig.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..78eb1d953 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -4,6 +4,7 @@ import android.Manifest import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import app.grapheneos.camera.data.camera.session.CameraSession import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.ui.activities.MainActivity import org.junit.Assert.assertEquals @@ -37,23 +38,27 @@ class ModeSwitchLatencyRegressionTest { var snapshotsFirstTime = false scenario.onActivity { - CamConfig.clearSnapshotProbeCache() + CameraSession.clearSnapshotProbeCache() it.camConfig.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, + CameraSession.snapshotProbeCount, + ) + snapshotsFirstTime = it.camConfig.session.imageCapture != null } scenario.onActivity { it.camConfig.switchMode(CameraMode.CAMERA) it.camConfig.switchMode(CameraMode.VIDEO) - assertEquals("the second entry probed again", 1, CamConfig.snapshotProbeCount) + assertEquals("the second entry probed again", 1, CameraSession.snapshotProbeCount) assertEquals( "the cached verdict answered for a different snapshot decision", snapshotsFirstTime, - it.camConfig.imageCapture != null + it.camConfig.session.imageCapture != null ) } } @@ -72,7 +77,7 @@ class ModeSwitchLatencyRegressionTest { awaitModeTabs(scenario) scenario.onActivity { - CamConfig.clearSnapshotProbeCache() + CameraSession.clearSnapshotProbeCache() it.camConfig.switchMode(CameraMode.VIDEO) } @@ -102,7 +107,7 @@ class ModeSwitchLatencyRegressionTest { assertEquals( "the new quality was answered from the old verdict", 2, - CamConfig.snapshotProbeCount + CameraSession.snapshotProbeCount ) } finally { selectVideoQuality(activity, original) @@ -120,16 +125,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.camConfig.session.zoomState != null + } scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) - assertNull("the bind read the zoom state", it.camConfig.zoomState) + assertNull("the bind read the zoom state", it.camConfig.session.zoomState) } waitUntil(scenario, "the zoom state is attached again") { - it.camConfig.zoomState != null + it.camConfig.session.zoomState != null } } } @@ -144,15 +151,17 @@ class ModeSwitchLatencyRegressionTest { awaitModeTabs(scenario) scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } - waitUntil(scenario, "the zoom state is attached") { it.camConfig.zoomState != null } + waitUntil(scenario, "the zoom state is attached") { + it.camConfig.session.zoomState != null + } - scenario.onActivity { it.camConfig.camera!!.cameraControl.setLinearZoom(0.5f) } + scenario.onActivity { it.camConfig.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.camConfig.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..eec964c42 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.camConfig.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..6f9d0bda2 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt @@ -37,7 +37,7 @@ 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.camConfig.session.camera != null } // The same instance More Settings edits, so it can be read while that screen is up val camConfig = camConfigOf(scenario) @@ -63,7 +63,7 @@ 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.camConfig.session.camera != null } val camConfig = camConfigOf(scenario) val stored = camConfig.photoQuality diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index 2d9f828bd..5f52c0f26 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt @@ -35,7 +35,7 @@ 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.camConfig.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. @@ -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.camConfig.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. @@ -98,7 +98,7 @@ 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.camConfig.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -124,7 +124,7 @@ 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.camConfig.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -152,7 +152,7 @@ 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.camConfig.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -183,7 +183,7 @@ 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.camConfig.session.camera != null } scenario.onActivity { activity -> activity.timerDuration = 5 diff --git a/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt index 38ec2868c..3805dd064 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.camConfig.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.camConfig.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..1ec08004a 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 @@ -276,7 +277,9 @@ class VideoCapturerRegressionTest { awaitModeTabs(scenario) scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } - waitUntil(scenario, "video use case is bound") { it.camConfig.videoCapture != null } + waitUntil(scenario, "video use case is bound") { + it.camConfig.session.videoCapture != null + } val capturedBefore = lastCapturedUri(scenario) @@ -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.camConfig.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 } + waitUntil(scenario, "video use case is bound") { + it.camConfig.session.videoCapture != null + } lateinit var player: ManualTunePlayer var mode: CameraMode? = null @@ -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.camConfig.session.camera != null && it.camConfig.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,7 +477,7 @@ 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 } diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 429f740de..f9da1a3bf 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -1,8 +1,6 @@ package app.grapheneos.camera import android.annotation.SuppressLint -import android.net.Uri -import android.provider.MediaStore import android.util.Log import androidx.annotation.VisibleForTesting import androidx.camera.core.AspectRatio @@ -28,6 +26,7 @@ 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.repository.SettingsRepository +import app.grapheneos.camera.di.core.MainImmediateDispatcher import app.grapheneos.camera.domain.camera.model.CameraEntryPoint import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality @@ -41,8 +40,8 @@ import dagger.assisted.AssistedInject import java.io.IOException import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -58,10 +57,11 @@ class CamConfig @AssistedInject constructor( private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, private val barcodeFormats: BarcodeFormats, + @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, cameraSessionFactory: CameraSession.Factory, ) { - private val session = cameraSessionFactory.create( + val session = cameraSessionFactory.create( environment = environment, listener = object : CameraSession.Listener { override fun onZoomStateChanged() { @@ -96,55 +96,12 @@ class CamConfig @AssistedInject constructor( }, ) - var camera: Camera? - get() = session.camera - set(value) { - session.camera = value - } - - val zoomState: ZoomState? - get() = session.zoomState - - val cameraProvider: ProcessCameraProvider? - get() = session.cameraProvider - - val imageCapture: ImageCapture? - get() = session.imageCapture - - val preview: Preview? - get() = session.preview - - val videoCapture: VideoCapture? - get() = session.videoCapture - - val iAnalyzer: ImageAnalysis? - get() = session.iAnalyzer - - var lensFacing: Int - get() = session.lensFacing - set(value) { - session.lensFacing = value - } - - val isFlashAvailable: Boolean - get() = session.isFlashAvailable - - var isTorchOn: Boolean - get() = session.isTorchOn - set(value) { - session.isTorchOn = value - } - - val isZslSupported: Boolean - get() = session.isZslSupported - - val allowedFormats: List - get() = barcodeFormats.enabled - @set:VisibleForTesting var mPlayer = environment.createTunePlayer() - private var settings: CameraSettings = runBlocking { settingsRepository.settings.first() } + private var settings: CameraSettings = runBlocking { + settingsRepository.settings.first() + } private var currentStorageLocation: String = runBlocking { capturedItemRepository.storageLocation.first() @@ -152,7 +109,7 @@ class CamConfig @AssistedInject constructor( private var modeSettings: ModeSettings = ModeSettings() - private val preferencesScope = CoroutineScope(Dispatchers.Main.immediate) + private val preferencesScope = CoroutineScope(mainDispatcher) var lastCapturedItem: CapturedItem? = null @@ -179,6 +136,12 @@ class CamConfig @AssistedInject constructor( } } + var currentMode: CameraMode = DEFAULT_CAMERA_MODE + private set + + var isQRMode = false + private set + var isVideoMode = false private set get() { @@ -187,47 +150,22 @@ class CamConfig @AssistedInject constructor( val canTakePicture: Boolean get() { - return imageCapture != null + return session.imageCapture != null } - var isQRMode = false - private set - - var currentMode: CameraMode = DEFAULT_CAMERA_MODE - private set - - var aspectRatio: Int + private val isInPhotoMode: Boolean 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) } - } + return !(isQRMode || isVideoMode) } + val isInCaptureMode: Boolean + get() = entryPoint.isCaptureSession + var gridType: GridType by setting( read = { it.gridType }, write = { current, value -> current.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 - var focusTimeout: Long by setting( read = { it.focusTimeoutSeconds }, write = { current, value -> current.copy(focusTimeoutSeconds = value) }, @@ -243,34 +181,6 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(enableCameraSounds = value) }, ) - var scanAllCodes: Boolean - get() { - return settings.scanAllCodes - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(scanAllCodes = value) } - } - - if (isQRMode) { - effects.applyScanAllCodesChrome(value) - } - - session.refreshQrHints() - } - - var includeAudio: Boolean - get() { - return settings.includeAudio - } - set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(includeAudio = value) } - } - - effects.onIncludeAudioChanged(value) - } - var enableEIS: Boolean by setting( read = { it.enableEis }, write = { current, value -> current.copy(enableEis = value) }, @@ -281,19 +191,6 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(saveImageAsPreviewed = value) }, ) - var storageLocation: String - get() { - return currentStorageLocation - } - set(value) { - runBlocking { - capturedItemRepository.setStorageLocation(value) - capturedItemRepository.releaseUntrackedSafTrees() - } - - currentStorageLocation = value - } - var photoQuality: Int by setting( read = { it.photoQuality }, write = { current, value -> current.copy(photoQuality = value) }, @@ -304,14 +201,52 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(removeExifAfterCapture = value) }, ) - private val isInPhotoMode: Boolean + var waitForFocusLock: Boolean by setting( + read = { it.waitForFocusLock }, + write = { current, value -> current.copy(waitForFocusLock = value) }, + ) + + var aspectRatio: Int get() { - return !(isQRMode || isVideoMode) + 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) } + } } - val isInCaptureMode: Boolean - get() { - return entryPoint.isCaptureSession + var scanAllCodes: Boolean by setting( + read = { it.scanAllCodes }, + write = { current, value -> current.copy(scanAllCodes = value) }, + onChanged = { value -> + if (isQRMode) { + effects.applyScanAllCodesChrome(value) + } + + session.refreshQrHints() + }, + ) + + var includeAudio: Boolean by setting( + read = { it.includeAudio }, + write = { current, value -> current.copy(includeAudio = value) }, + onChanged = { value -> effects.onIncludeAudioChanged(value) }, + ) + + var flashMode: Int = SettingsDefaults.FLASH_MODE + private set + + var videoQuality: Quality + get() = modeSettings.videoQuality + set(value) { + runBlocking { + settingsRepository.setVideoQuality(value) + }?.let { modeSettings = it } } // Session state rather than the stored value: geo-tagging is only ever on once the permission @@ -335,7 +270,7 @@ class CamConfig @AssistedInject constructor( var selfIlluminate: Boolean get() { return modeSettings.selfIllumination && - lensFacing == CameraSelector.LENS_FACING_FRONT + session.lensFacing == CameraSelector.LENS_FACING_FRONT } set(value) { runBlocking { @@ -345,33 +280,19 @@ class CamConfig @AssistedInject constructor( effects.onSelfIlluminationChanged(value) } - var waitForFocusLock: Boolean by setting( - read = { it.waitForFocusLock }, - write = { current, value -> current.copy(waitForFocusLock = value) }, - ) - - fun canApplyVideoStabilization(): Boolean { - return session.canApplyVideoStabilization() - } - - fun toggleTorchState() { - session.toggleTorchState() - } - - private fun setting( - read: (CameraSettings) -> T, - write: (CameraSettings, T) -> CameraSettings, - ): ReadWriteProperty { - return object : ReadWriteProperty { - override fun getValue(thisRef: Any?, property: KProperty<*>): T { - return read(settings) + var storageLocation: String + get() = currentStorageLocation + set(value) { + runBlocking { + capturedItemRepository.setStorageLocation(value) + capturedItemRepository.releaseUntrackedSafTrees() } - override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - settings = runBlocking { settingsRepository.update { write(it, value) } } - } + currentStorageLocation = value } - } + + val allowedFormats: List + get() = barcodeFormats.enabled fun onDestroy() { preferencesScope.cancel() @@ -399,7 +320,7 @@ class CamConfig @AssistedInject constructor( private fun applyFlashMode(value: Int) { flashMode = value - imageCapture?.flashMode = value + session.imageCapture?.flashMode = value effects.onFlashModeChanged() } @@ -429,7 +350,7 @@ class CamConfig @AssistedInject constructor( modeSettings = runBlocking { settingsRepository.selectMode( mode = currentMode, - isFrontFacing = lensFacing == CameraSelector.LENS_FACING_FRONT, + isFrontFacing = session.lensFacing == CameraSelector.LENS_FACING_FRONT, ) } } @@ -466,7 +387,7 @@ class CamConfig @AssistedInject constructor( } fun toggleFlashMode() { - if (isFlashAvailable) { + if (session.isFlashAvailable) { val next = when (flashMode) { ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO @@ -489,8 +410,8 @@ class CamConfig @AssistedInject constructor( fun toggleCameraSelector() { // Manually switch to the opposite lens facing - lensFacing = - if (lensFacing == CameraSelector.LENS_FACING_BACK) { + session.lensFacing = + if (session.lensFacing == CameraSelector.LENS_FACING_BACK) { CameraSelector.LENS_FACING_FRONT } else { CameraSelector.LENS_FACING_BACK @@ -498,12 +419,12 @@ class CamConfig @AssistedInject constructor( // 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 (session.isLensFacingSupported(lensFacing, currentMode.extensionMode)) { + if (session.isLensFacingSupported(session.lensFacing, currentMode.extensionMode)) { 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) { + session.lensFacing = if (session.lensFacing == CameraSelector.LENS_FACING_BACK) { effects.showMessage(R.string.rear_camera_unavailable) CameraSelector.LENS_FACING_FRONT } else { @@ -544,7 +465,7 @@ class CamConfig @AssistedInject constructor( // Start the camera with latest hard configuration fun startCamera(forced: Boolean = false) { - if ((!forced && camera != null) || cameraProvider == null) return + if ((!forced && session.camera != null) || session.cameraProvider == null) return // Cancel any pending capture requests effects.cancelPendingCapture() @@ -565,15 +486,15 @@ class CamConfig @AssistedInject constructor( // (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 (!session.isLensFacingSupported(lensFacing, currentMode.extensionMode)) { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { + if (!session.isLensFacingSupported(session.lensFacing, currentMode.extensionMode)) { + session.lensFacing = if (session.lensFacing == CameraSelector.LENS_FACING_BACK) { CameraSelector.LENS_FACING_FRONT } else { CameraSelector.LENS_FACING_BACK } } - session.selectLensFacing(lensFacing) + 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 @@ -652,11 +573,11 @@ class CamConfig @AssistedInject constructor( effects.updateZoomThumb(shouldShowPanel = false) - camera?.cameraInfo?.exposureState?.let { effects.applyExposureState(it) } + session.camera?.cameraInfo?.exposureState?.let { effects.applyExposureState(it) } effects.resetTorchToggle() - camera?.cameraInfo?.let { effects.onPreviewBound(aspectRatio, it) } + session.camera?.cameraInfo?.let { effects.onPreviewBound(aspectRatio, it) } effects.updateGyroscopeIndicator(isInPhotoMode) } @@ -757,6 +678,24 @@ class CamConfig @AssistedInject constructor( effects.showStorageLocationNotFound() } + 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) { + settings = runBlocking { settingsRepository.update { write(it, value) } } + + onChanged(value) + } + } + } + @AssistedFactory interface Factory { fun create( @@ -769,22 +708,5 @@ class CamConfig @AssistedInject constructor( private const val TAG = "CamConfig" val DEFAULT_CAMERA_MODE = CameraMode.CAMERA - - 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" } - - @VisibleForTesting - val snapshotProbeCount: Int - get() = CameraSession.snapshotProbeCount - - @VisibleForTesting - fun clearSnapshotProbeCache() { - CameraSession.clearSnapshotProbeCache() - } } } 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..6fe02b5e4 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt @@ -59,7 +59,7 @@ class ImageCapturer(val mActivity: MainActivity) { @SuppressLint("RestrictedApi") fun takePicture() { - if (camConfig.camera == null) { + if (camConfig.session.camera == null) { return } @@ -73,7 +73,8 @@ class ImageCapturer(val mActivity: MainActivity) { } val imageMetadata = ImageCapture.Metadata() - imageMetadata.isReversedHorizontal = camConfig.lensFacing == CameraSelector.LENS_FACING_FRONT + imageMetadata.isReversedHorizontal = + camConfig.session.lensFacing == CameraSelector.LENS_FACING_FRONT && camConfig.saveImageAsPreviewed if (camConfig.requireLocation) { @@ -87,7 +88,7 @@ class ImageCapturer(val mActivity: MainActivity) { val preview = mActivity.imagePreview - val imageCapture = camConfig.imageCapture!! + val imageCapture = camConfig.session.imageCapture!! val imageSaver = ImageSaver( this, 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..5f6249622 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 @@ -114,7 +114,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) @@ -146,8 +146,8 @@ class VideoCapturer(private val mActivity: MainActivity) { } fun startRecording() { - if (camConfig.camera == null) return - val recorder = camConfig.videoCapture?.output ?: return + if (camConfig.session.camera == null) return + val recorder = camConfig.session.videoCapture?.output ?: return if (isRecording) return isRecording = true @@ -484,7 +484,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/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/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/ui/SettingsDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt index d2fe667ba..00d5e16b7 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -206,8 +206,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : torchToggle = binding.torchToggleOption torchToggle.setOnClickListener { - if (camConfig.isFlashAvailable) { - camConfig.toggleTorchState() + if (camConfig.session.isFlashAvailable) { + camConfig.session.toggleTorchState() } else { torchToggle.isChecked = false mActivity.showMessage( @@ -253,7 +253,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : waitForFocusLockSwitch.isChecked = camConfig.waitForFocusLock waitForFocusLockSwitch.setOnClickListener { camConfig.waitForFocusLock = waitForFocusLockSwitch.isChecked - if (camConfig.cameraProvider != null) { + if (camConfig.session.cameraProvider != null) { camConfig.startCamera(true) } } @@ -497,7 +497,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : if (camConfig.isVideoMode) { includeAudioSetting.visibility = View.VISIBLE videoQualitySetting.visibility = View.VISIBLE - enableEISSetting.visibility = if (camConfig.canApplyVideoStabilization()) { + enableEISSetting.visibility = if (camConfig.session.canApplyVideoStabilization()) { View.VISIBLE } else { View.GONE @@ -509,7 +509,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } selfIlluminationSetting.visibility = - if (camConfig.lensFacing == CameraSelector.LENS_FACING_FRONT) { + if (camConfig.session.lensFacing == CameraSelector.LENS_FACING_FRONT) { View.VISIBLE } else { View.GONE @@ -759,7 +759,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } private fun getAvailableQualities(): List { - val cameraInfo = camConfig.camera?.cameraInfo ?: return Collections.emptyList() + val cameraInfo = camConfig.session.camera?.cameraInfo ?: return Collections.emptyList() return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } @@ -785,7 +785,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun updateFlashMode() { - val (icon, description) = if (camConfig.isFlashAvailable) { + val (icon, description) = if (camConfig.session.isFlashAvailable) { when (camConfig.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 @@ -820,7 +820,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : updateAspectRatioToggle(camConfig.aspectRatio == AspectRatio.RATIO_16_9) } - torchToggle.isChecked = camConfig.isTorchOn + torchToggle.isChecked = camConfig.session.isTorchOn updateGridToggleUI() 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..be5737f19 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( + camConfig.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() + camConfig.session.cameraProvider?.unbindAll() mainOverlay.setImageBitmap(bitmap) mainOverlay.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 aabca1f21..2bbaf8e98 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 @@ -284,7 +284,7 @@ open class MainActivity : AppCompatActivity() { QROverlay.RATIO ) - camConfig.camera?.cameraControl?.startFocusAndMetering( + camConfig.session.camera?.cameraControl?.startFocusAndMetering( FocusMeteringAction.Builder(autoFocusPoint).disableAutoCancel().build() ) @@ -904,8 +904,8 @@ open class MainActivity : AppCompatActivity() { videoCapturer.startRecording() } } else if (camConfig.isQRMode) { - camConfig.toggleTorchState() - if (camConfig.isTorchOn) { + camConfig.session.toggleTorchState() + if (camConfig.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) @@ -1323,7 +1323,7 @@ open class MainActivity : AppCompatActivity() { camConfig.startCamera(true) } - camConfig.cameraProvider?.unbindAll() + camConfig.session.cameraProvider?.unbindAll() builder.showIgnoringShortEdgeMode() } @@ -1418,8 +1418,9 @@ 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 { + camConfig.session.preview?.targetRotation = + previewView.display?.rotation ?: Surface.ROTATION_0 + camConfig.session.camera?.cameraInfo?.let { previewView.applyPreviewRatio(camConfig.aspectRatio, it) } @@ -1500,6 +1501,7 @@ open class MainActivity : AppCompatActivity() { } } + @SuppressLint("MissingPermission") private fun requestLocation(reAttach: Boolean = false) { when { ActivityCompat.shouldShowRequestPermissionRationale( 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 c94441020..389e65fd6 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 @@ -388,7 +388,7 @@ open class MoreSettings : val flavor = if (caller is SecureActivity) MoreSettingsSecure::class else MoreSettings::class Intent(caller, flavor.java).let { it.putExtra(INTENT_EXTRA_IN_CAPTURE_MODE, caller.camConfig.isInCaptureMode) - it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.camConfig.isZslSupported) + it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.camConfig.session.isZslSupported) caller.startActivity(it) } 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..1dcd026ad 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.camConfig.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..f1c59d77a 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 @@ -85,7 +85,7 @@ class ZoomBar : AppCompatSeekBar { } fun updateThumb(shouldShowPanel: Boolean = true) { - val zoomState: ZoomState? = camConfig.zoomState + val zoomState: ZoomState? = camConfig.session.zoomState if (shouldShowPanel) { showPanel() @@ -147,7 +147,7 @@ class ZoomBar : AppCompatSeekBar { if (progress < 1) progress = 1 if (progress > 100) progress = 100 - camConfig.camera?.cameraControl?.setLinearZoom(progress / 100f) + camConfig.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 index d8d4dacb0..433fe13c3 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -49,7 +49,7 @@ internal class ViewfinderGestureHandler( val x = event.x val y = event.y - val camera = activity.camConfig.camera ?: return true + val camera = activity.camConfig.session.camera ?: return true val autoFocusPoint = activity.previewView.meteringPointFactory.createPoint(x, y) activity.animateFocusRing(x, y) @@ -80,14 +80,14 @@ internal class ViewfinderGestureHandler( override fun onScale(detector: ScaleGestureDetector): Boolean { isZooming = true - val zoomState = activity.camConfig.zoomState + val zoomState = activity.camConfig.session.zoomState var scale = 1f if (zoomState != null) { scale = zoomState.zoomRatio * detector.scaleFactor } - val camera = activity.camConfig.camera ?: return true + val camera = activity.camConfig.session.camera ?: return true camera.cameraControl.setZoomRatio(scale) return true 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 index 6017d0704..a3a46b11f 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt @@ -61,9 +61,9 @@ internal class ViewfinderOrientationHandler( else -> Surface.ROTATION_0 } - activity.camConfig.imageCapture?.targetRotation = targetRotation - activity.camConfig.videoCapture?.targetRotation = targetRotation - activity.camConfig.iAnalyzer?.targetRotation = targetRotation + activity.camConfig.session.imageCapture?.targetRotation = targetRotation + activity.camConfig.session.videoCapture?.targetRotation = targetRotation + activity.camConfig.session.iAnalyzer?.targetRotation = targetRotation if (activity.videoCapturer.isRecording) return 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..d05752945 100644 --- a/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt +++ b/app/src/main/java/app/grapheneos/camera/util/CameraControl.kt @@ -5,7 +5,7 @@ import app.grapheneos.camera.CamConfig class CameraControl(private val camConfig: CamConfig) { - private fun zoomState(): ZoomState? = camConfig.zoomState + private fun zoomState(): ZoomState? = camConfig.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) + camConfig.session.camera?.cameraControl?.setZoomRatio(zoomTo) } } From 96b80a01158f819e3c3eb54d3b0480ea3eed95f8 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Tue, 8 Sep 2026 12:30:52 +0200 Subject: [PATCH 17/23] Make CamConfig listener on camera session --- app/config/ktlint/baseline.xml | 266 +++++++++--------- app/detekt-baseline-debug.xml | 2 +- .../camera/BottomTabLayoutRegressionTest.kt | 2 +- .../camera/CameraModeTabsRegressionTest.kt | 4 +- .../camera/ModeSwitchLatencyRegressionTest.kt | 16 +- .../java/app/grapheneos/camera/ModeTabs.kt | 2 +- .../camera/PhotoQualityRegressionTest.kt | 4 +- .../camera/SelfTimerRegressionTest.kt | 12 +- .../camera/SettingsDialogRegressionTest.kt | 4 +- .../camera/VideoCapturerRegressionTest.kt | 8 +- .../java/app/grapheneos/camera/CamConfig.kt | 52 ++-- .../camera/capturer/ImageCapturer.kt | 8 +- .../camera/capturer/VideoCapturer.kt | 6 +- .../data/camera/session/CameraSession.kt | 20 +- .../grapheneos/camera/ui/SettingsDialog.kt | 18 +- .../camera/ui/activities/CaptureActivity.kt | 4 +- .../camera/ui/activities/MainActivity.kt | 22 +- .../camera/ui/activities/MoreSettings.kt | 2 +- .../camera/ui/seekbar/ExposureBar.kt | 2 +- .../grapheneos/camera/ui/seekbar/ZoomBar.kt | 8 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 6 +- .../ViewfinderOrientationHandler.kt | 6 +- .../grapheneos/camera/util/CameraControl.kt | 8 +- 23 files changed, 241 insertions(+), 241 deletions(-) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index e1b743da6..999e30180 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -196,33 +196,33 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -276,36 +276,36 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -386,74 +386,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,10 +529,10 @@ - - - - + + + + @@ -591,14 +591,14 @@ - - - - + + + + - + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 6a3a2e881..ff87cb7d5 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -177,7 +177,7 @@ UnsafeCallOnNullableType:BlurBitmap.kt:BlurBitmap$sentBitmap.config!! UnsafeCallOnNullableType:BottomTabLayout.kt:BottomTabLayout$getTabAt(it)!! UnsafeCallOnNullableType:CapturedItems.kt:CapturedItem.Companion.<no name provided>$source.readString()!! - UnsafeCallOnNullableType:ImageCapturer.kt:ImageCapturer$camConfig.session.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()!! diff --git a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 7e464faa7..22cca8cea 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt @@ -371,7 +371,7 @@ class BottomTabLayoutRegressionTest { Thread.sleep(RETRY_WINDOW_MS) scenario.moveToState(Lifecycle.State.RESUMED) - waitUntil(scenario, "the camera came back") { it.camConfig.session.camera != null } + waitUntil(scenario, "the camera came back") { it.session.camera != null } } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt index 8e7dfb439..19800865c 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/CameraModeTabsRegressionTest.kt @@ -131,7 +131,7 @@ class CameraModeTabsRegressionTest { launchCaptureSession(CaptureActivity::class.java, MediaStore.ACTION_IMAGE_CAPTURE) .use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.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) @@ -144,7 +144,7 @@ class CameraModeTabsRegressionTest { } private fun assertNoModeTabs(scenario: ActivityScenario) { - waitUntil(scenario, "camera is bound") { it.camConfig.session.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 78eb1d953..2e7a20df6 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -47,7 +47,7 @@ class ModeSwitchLatencyRegressionTest { 1, CameraSession.snapshotProbeCount, ) - snapshotsFirstTime = it.camConfig.session.imageCapture != null + snapshotsFirstTime = it.session.imageCapture != null } scenario.onActivity { @@ -58,7 +58,7 @@ class ModeSwitchLatencyRegressionTest { assertEquals( "the cached verdict answered for a different snapshot decision", snapshotsFirstTime, - it.camConfig.session.imageCapture != null + it.session.imageCapture != null ) } } @@ -126,17 +126,17 @@ class ModeSwitchLatencyRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) waitUntil(scenario, "the zoom state is attached") { - it.camConfig.session.zoomState != null + it.session.zoomState != null } scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) - assertNull("the bind read the zoom state", it.camConfig.session.zoomState) + assertNull("the bind read the zoom state", it.session.zoomState) } waitUntil(scenario, "the zoom state is attached again") { - it.camConfig.session.zoomState != null + it.session.zoomState != null } } } @@ -152,16 +152,16 @@ class ModeSwitchLatencyRegressionTest { scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } waitUntil(scenario, "the zoom state is attached") { - it.camConfig.session.zoomState != null + it.session.zoomState != null } - scenario.onActivity { it.camConfig.session.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.session.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 eec964c42..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.session.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 6f9d0bda2..a831dcad7 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt @@ -37,7 +37,7 @@ class PhotoQualityRegressionTest { @Test fun photoQualityOutOfRange_isNotCommitted() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.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) @@ -63,7 +63,7 @@ class PhotoQualityRegressionTest { @Test fun photoQualityInRange_isCommitted() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } val camConfig = camConfigOf(scenario) val stored = camConfig.photoQuality diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index 5f52c0f26..ba71d6411 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt @@ -35,7 +35,7 @@ class SelfTimerRegressionTest { @Test fun selfTimer_isCancelledWhenTheActivityPauses() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.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. @@ -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.session.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. @@ -98,7 +98,7 @@ class SelfTimerRegressionTest { @Test fun selfTimerBadge_tracksThePendingDurationInPhotoMode() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -124,7 +124,7 @@ class SelfTimerRegressionTest { @Test fun captureButton_withATimerSet_startsTheCountdownInsteadOfCapturing() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -152,7 +152,7 @@ class SelfTimerRegressionTest { @Test fun captureButton_duringACountdown_cancelsItAndPutsTheControlsBack() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.camConfig.switchMode(CameraMode.CAMERA) @@ -183,7 +183,7 @@ class SelfTimerRegressionTest { @Test fun selfTimerBadge_staysHiddenWhereNoPhotoCanBeTaken() { ActivityScenario.launch(VideoOnlyActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.camera != null } + waitUntil(scenario, "camera is bound") { it.session.camera != null } scenario.onActivity { activity -> activity.timerDuration = 5 diff --git a/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SettingsDialogRegressionTest.kt index 3805dd064..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.session.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.session.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 1ec08004a..a7b824c32 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt @@ -278,7 +278,7 @@ class VideoCapturerRegressionTest { scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } waitUntil(scenario, "video use case is bound") { - it.camConfig.session.videoCapture != null + it.session.videoCapture != null } val capturedBefore = lastCapturedUri(scenario) @@ -353,12 +353,12 @@ class VideoCapturerRegressionTest { @Test fun tappingAModeTabDuringTheDeferredStart_leavesTheModeAlone() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - waitUntil(scenario, "camera is bound") { it.camConfig.session.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.session.videoCapture != null + it.session.videoCapture != null } lateinit var player: ManualTunePlayer @@ -413,7 +413,7 @@ class VideoCapturerRegressionTest { ) { launch().use { scenario -> waitUntil(scenario, "video use case is bound") { - it.camConfig.session.camera != null && it.camConfig.session.videoCapture != null + it.session.camera != null && it.session.videoCapture != null } val capturedBefore = lastCapturedUri(scenario) try { diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index f9da1a3bf..22984b106 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -51,6 +51,7 @@ import kotlinx.coroutines.runBlocking class CamConfig @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, @Assisted private val effects: ViewfinderEffects, + @Assisted private val session: CameraSession, private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, private val capturedItemRepository: CapturedItemRepository, @@ -58,43 +59,23 @@ class CamConfig @AssistedInject constructor( private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, private val barcodeFormats: BarcodeFormats, @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, - cameraSessionFactory: CameraSession.Factory, -) { - - val session = cameraSessionFactory.create( - environment = environment, - listener = object : CameraSession.Listener { - override fun onZoomStateChanged() { - effects.updateZoomThumb(shouldShowPanel = true) - } +) : CameraSession.Listener { - override fun onCameraProviderUnavailable() { - effects.showMessage(R.string.camera_provider_init_failure) - } + override fun onZoomStateChanged() { + effects.updateZoomThumb(shouldShowPanel = true) + } - override fun onExtensionsUnavailable() { - effects.showMessage(R.string.extensions_manager_init_failure) - } + override fun onCameraProviderUnavailable() { + effects.showMessage(R.string.camera_provider_init_failure) + } - override fun onProviderReady(forced: Boolean) { - startCamera(forced = forced) - } + override fun onExtensionsUnavailable() { + effects.showMessage(R.string.extensions_manager_init_failure) + } - override fun onFeaturesSelected( - boundLensFacing: Int, - requested: List, - qualityFeature: GroupableFeature?, - selected: Set, - ) { - this@CamConfig.onFeaturesSelected( - boundLensFacing, - requested, - qualityFeature, - selected, - ) - } - }, - ) + override fun onProviderReady(forced: Boolean) { + startCamera(forced = forced) + } @set:VisibleForTesting var mPlayer = environment.createTunePlayer() @@ -114,6 +95,8 @@ class CamConfig @AssistedInject constructor( var lastCapturedItem: CapturedItem? = null init { + session.listener = this + preferencesScope.launch { settingsRepository.settings.collect { settings = it } } @@ -434,7 +417,7 @@ class CamConfig @AssistedInject constructor( } } - private fun onFeaturesSelected( + override fun onFeaturesSelected( boundLensFacing: Int, requested: List, qualityFeature: GroupableFeature?, @@ -701,6 +684,7 @@ class CamConfig @AssistedInject constructor( fun create( environment: CameraSessionEnvironment, effects: ViewfinderEffects, + session: CameraSession, ): CamConfig } 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 6fe02b5e4..fd6d3b440 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt @@ -30,6 +30,8 @@ private const val imageFileFormat = ".jpg" class ImageCapturer(val mActivity: MainActivity) { val camConfig = mActivity.camConfig + private val session = mActivity.session + val isTakingPicture: Boolean get() = currentImageSaver != null @@ -59,7 +61,7 @@ class ImageCapturer(val mActivity: MainActivity) { @SuppressLint("RestrictedApi") fun takePicture() { - if (camConfig.session.camera == null) { + if (session.camera == null) { return } @@ -74,7 +76,7 @@ class ImageCapturer(val mActivity: MainActivity) { val imageMetadata = ImageCapture.Metadata() imageMetadata.isReversedHorizontal = - camConfig.session.lensFacing == CameraSelector.LENS_FACING_FRONT + session.lensFacing == CameraSelector.LENS_FACING_FRONT && camConfig.saveImageAsPreviewed if (camConfig.requireLocation) { @@ -88,7 +90,7 @@ class ImageCapturer(val mActivity: MainActivity) { val preview = mActivity.imagePreview - val imageCapture = camConfig.session.imageCapture!! + val imageCapture = session.imageCapture!! val imageSaver = ImageSaver( this, 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 5f6249622..45105617d 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -47,6 +47,8 @@ class VideoCapturer(private val mActivity: MainActivity) { val camConfig = mActivity.camConfig + private val session = mActivity.session + var isRecording = false private set @@ -146,8 +148,8 @@ class VideoCapturer(private val mActivity: MainActivity) { } fun startRecording() { - if (camConfig.session.camera == null) return - val recorder = camConfig.session.videoCapture?.output ?: return + if (session.camera == null) return + val recorder = session.videoCapture?.output ?: return if (isRecording) return isRecording = true 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 index ce0c9d4e5..090a17ac9 100644 --- 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 @@ -50,7 +50,6 @@ import kotlin.concurrent.thread @SuppressLint("UnsafeOptInUsageError") class CameraSession @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, - @Assisted private val listener: Listener, private val cameraProviderSource: CameraProviderSource, private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, private val featureCombinationSupport: FeatureCombinationSupport, @@ -59,6 +58,8 @@ class CameraSession @AssistedInject constructor( private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, ) { + var listener: Listener? = null + var camera: Camera? = null var imageCapture: ImageCapture? = null @@ -88,7 +89,7 @@ class CameraSession @AssistedInject constructor( private val zoomStateObserver = Observer { zoomState = it if (it.linearZoom != 0f || it.zoomRatio != 1f) { - listener.onZoomStateChanged() + listener?.onZoomStateChanged() } } @@ -212,13 +213,13 @@ class CameraSession @AssistedInject constructor( fun initialize(forced: Boolean, extensionMode: Int) { if (cameraProvider != null) { - listener.onProviderReady(forced) + listener?.onProviderReady(forced) return } cameraProviderSource.acquireProvider(environment.sessionContext) { provider -> when (provider) { - null -> listener.onCameraProviderUnavailable() + null -> listener?.onCameraProviderUnavailable() else -> onCameraProviderReady(provider, forced, extensionMode) } } @@ -253,11 +254,11 @@ class CameraSession @AssistedInject constructor( provider, ) { manager -> when (manager) { - null -> listener.onExtensionsUnavailable() + null -> listener?.onExtensionsUnavailable() else -> extensionsManager = manager } - listener.onProviderReady(forced) + listener?.onProviderReady(forced) } } @@ -682,7 +683,7 @@ class CameraSession @AssistedInject constructor( sessionConfig.setFeatureSelectionListener( environment.sessionMainExecutor ) { selected -> - listener.onFeaturesSelected( + listener?.onFeaturesSelected( boundLensFacing = boundLensFacing, requested = requested, qualityFeature = requiredQualityFeature, @@ -768,10 +769,7 @@ class CameraSession @AssistedInject constructor( @AssistedFactory interface Factory { - fun create( - environment: CameraSessionEnvironment, - listener: Listener, - ): CameraSession + fun create(environment: CameraSessionEnvironment): CameraSession } private data class SnapshotProbeKey( 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 00d5e16b7..6a0c8720e 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -55,6 +55,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : Dialog(themedContext) { val camConfig = mActivity.camConfig + private val session = mActivity.session + private val binding: SettingsBinding by lazy { SettingsBinding.inflate(layoutInflater) } private var dialog: View var locToggle: ToggleButton @@ -206,8 +208,8 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : torchToggle = binding.torchToggleOption torchToggle.setOnClickListener { - if (camConfig.session.isFlashAvailable) { - camConfig.session.toggleTorchState() + if (session.isFlashAvailable) { + session.toggleTorchState() } else { torchToggle.isChecked = false mActivity.showMessage( @@ -253,7 +255,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : waitForFocusLockSwitch.isChecked = camConfig.waitForFocusLock waitForFocusLockSwitch.setOnClickListener { camConfig.waitForFocusLock = waitForFocusLockSwitch.isChecked - if (camConfig.session.cameraProvider != null) { + if (session.cameraProvider != null) { camConfig.startCamera(true) } } @@ -497,7 +499,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : if (camConfig.isVideoMode) { includeAudioSetting.visibility = View.VISIBLE videoQualitySetting.visibility = View.VISIBLE - enableEISSetting.visibility = if (camConfig.session.canApplyVideoStabilization()) { + enableEISSetting.visibility = if (session.canApplyVideoStabilization()) { View.VISIBLE } else { View.GONE @@ -509,7 +511,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } selfIlluminationSetting.visibility = - if (camConfig.session.lensFacing == CameraSelector.LENS_FACING_FRONT) { + if (session.lensFacing == CameraSelector.LENS_FACING_FRONT) { View.VISIBLE } else { View.GONE @@ -759,7 +761,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } private fun getAvailableQualities(): List { - val cameraInfo = camConfig.session.camera?.cameraInfo ?: return Collections.emptyList() + val cameraInfo = session.camera?.cameraInfo ?: return Collections.emptyList() return Recorder.getVideoCapabilities(cameraInfo).getSupportedQualities(DynamicRange.SDR) } @@ -785,7 +787,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } fun updateFlashMode() { - val (icon, description) = if (camConfig.session.isFlashAvailable) { + val (icon, description) = if (session.isFlashAvailable) { when (camConfig.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 @@ -820,7 +822,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : updateAspectRatioToggle(camConfig.aspectRatio == AspectRatio.RATIO_16_9) } - torchToggle.isChecked = camConfig.session.isTorchOn + torchToggle.isChecked = session.isTorchOn updateGridToggleUI() 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 be5737f19..7858aeca2 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.session.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.session.cameraProvider?.unbindAll() + session.cameraProvider?.unbindAll() mainOverlay.setImageBitmap(bitmap) mainOverlay.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 2bbaf8e98..8d1b0b3a7 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 @@ -78,6 +78,7 @@ 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.core.model.CameraMode import app.grapheneos.camera.data.settings.repository.SettingsRepository import app.grapheneos.camera.databinding.ActivityMainBinding @@ -120,12 +121,17 @@ import kotlin.math.roundToInt @AndroidEntryPoint open class MainActivity : AppCompatActivity() { + @Inject + lateinit var cameraSessionFactory: CameraSession.Factory + @Inject lateinit var camConfigFactory: CamConfig.Factory @Inject lateinit var settingsRepository: SettingsRepository + lateinit var session: CameraSession + lateinit var camConfig: CamConfig private val application: App @@ -284,7 +290,7 @@ open class MainActivity : AppCompatActivity() { QROverlay.RATIO ) - camConfig.session.camera?.cameraControl?.startFocusAndMetering( + session.camera?.cameraControl?.startFocusAndMetering( FocusMeteringAction.Builder(autoFocusPoint).disableAutoCancel().build() ) @@ -780,11 +786,13 @@ open class MainActivity : AppCompatActivity() { snackBar = Snackbar.make(binding.root, "", Snackbar.LENGTH_LONG) val sessionHandler = ViewfinderEffectHandler(this) + session = cameraSessionFactory.create(environment = sessionHandler) camConfig = camConfigFactory.create( environment = sessionHandler, effects = sessionHandler, + session = session, ) - cameraControl = CameraControl(camConfig) + cameraControl = CameraControl(session) imageCapturer = ImageCapturer(this) videoCapturer = VideoCapturer(this) previewView.scaleType = PreviewView.ScaleType.FIT_START @@ -904,8 +912,8 @@ open class MainActivity : AppCompatActivity() { videoCapturer.startRecording() } } else if (camConfig.isQRMode) { - camConfig.session.toggleTorchState() - if (camConfig.session.isTorchOn) { + 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) @@ -1323,7 +1331,7 @@ open class MainActivity : AppCompatActivity() { camConfig.startCamera(true) } - camConfig.session.cameraProvider?.unbindAll() + session.cameraProvider?.unbindAll() builder.showIgnoringShortEdgeMode() } @@ -1418,9 +1426,9 @@ 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.session.preview?.targetRotation = + session.preview?.targetRotation = previewView.display?.rotation ?: Surface.ROTATION_0 - camConfig.session.camera?.cameraInfo?.let { + session.camera?.cameraInfo?.let { previewView.applyPreviewRatio(camConfig.aspectRatio, it) } 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 389e65fd6..79946ccf9 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 @@ -388,7 +388,7 @@ open class MoreSettings : val flavor = if (caller is SecureActivity) MoreSettingsSecure::class else MoreSettings::class Intent(caller, flavor.java).let { it.putExtra(INTENT_EXTRA_IN_CAPTURE_MODE, caller.camConfig.isInCaptureMode) - it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.camConfig.session.isZslSupported) + it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.session.isZslSupported) caller.startActivity(it) } 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 1dcd026ad..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.session.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 f1c59d77a..656ea2fe4 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 @@ -20,6 +20,7 @@ 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 kotlin.math.roundToInt @@ -48,9 +49,12 @@ class ZoomBar : AppCompatSeekBar { private lateinit var mainActivity: MainActivity private lateinit var camConfig: CamConfig + private lateinit var session: CameraSession + fun setMainActivity(mainActivity: MainActivity) { this.mainActivity = mainActivity camConfig = mainActivity.camConfig + session = mainActivity.session } fun showPanel() { @@ -85,7 +89,7 @@ class ZoomBar : AppCompatSeekBar { } fun updateThumb(shouldShowPanel: Boolean = true) { - val zoomState: ZoomState? = camConfig.session.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.session.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 index 433fe13c3..79e914bae 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -49,7 +49,7 @@ internal class ViewfinderGestureHandler( val x = event.x val y = event.y - val camera = activity.camConfig.session.camera ?: return true + val camera = activity.session.camera ?: return true val autoFocusPoint = activity.previewView.meteringPointFactory.createPoint(x, y) activity.animateFocusRing(x, y) @@ -80,14 +80,14 @@ internal class ViewfinderGestureHandler( override fun onScale(detector: ScaleGestureDetector): Boolean { isZooming = true - val zoomState = activity.camConfig.session.zoomState + val zoomState = activity.session.zoomState var scale = 1f if (zoomState != null) { scale = zoomState.zoomRatio * detector.scaleFactor } - val camera = activity.camConfig.session.camera ?: return true + val camera = activity.session.camera ?: return true camera.cameraControl.setZoomRatio(scale) return true 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 index a3a46b11f..1a54b535c 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt @@ -61,9 +61,9 @@ internal class ViewfinderOrientationHandler( else -> Surface.ROTATION_0 } - activity.camConfig.session.imageCapture?.targetRotation = targetRotation - activity.camConfig.session.videoCapture?.targetRotation = targetRotation - activity.camConfig.session.iAnalyzer?.targetRotation = targetRotation + activity.session.imageCapture?.targetRotation = targetRotation + activity.session.videoCapture?.targetRotation = targetRotation + activity.session.iAnalyzer?.targetRotation = targetRotation if (activity.videoCapturer.isRecording) return 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 d05752945..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.session.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.session.camera?.cameraControl?.setZoomRatio(zoomTo) + session.camera?.cameraControl?.setZoomRatio(zoomTo) } } From ba8f08a12303cd98ac5cbb138d4efc9573bb1857 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Tue, 8 Sep 2026 15:16:28 +0200 Subject: [PATCH 18/23] Turn CamConfig into viewfinder controller --- app/config/ktlint/baseline.xml | 21 +- app/detekt-baseline-debug.xml | 2 +- .../camera/BottomTabLayoutRegressionTest.kt | 34 +- .../camera/CameraModeTabsRegressionTest.kt | 12 +- .../camera/ModeSwitchLatencyRegressionTest.kt | 16 +- .../camera/PhotoQualityRegressionTest.kt | 23 +- .../camera/SafTreeGrantsRegressionTest.kt | 2 + .../camera/SelfTimerRegressionTest.kt | 14 +- .../camera/VideoCapturerRegressionTest.kt | 34 +- .../java/app/grapheneos/camera/TunePlayer.kt | 2 +- .../grapheneos/camera/analyzer/QRAnalyzer.kt | 14 +- .../camera/capturer/ImageCapturer.kt | 22 +- .../camera/capturer/VideoCapturer.kt | 20 +- .../data/camera/session/CameraSession.kt | 7 +- .../camera/data/core/model/CameraMode.kt | 11 + .../repository/CapturedItemRepository.kt | 104 +++-- .../camera/di/gallery/GalleryBindsModule.kt | 37 ++ .../domain/gallery/CapturedItemSession.kt | 112 +++++ .../gallery/mapper/VisibleCaptureMapper.kt | 22 + .../usecase/RevertToMediaStoreLocation.kt | 18 + .../camera/domain/qr/BarcodeFormats.kt | 11 +- .../SensorOrientationChangeNotifier.kt | 2 +- .../grapheneos/camera/ui/CountDownTimerUI.kt | 10 +- .../app/grapheneos/camera/ui/CustomGrid.kt | 10 +- .../java/app/grapheneos/camera/ui/QRToggle.kt | 10 +- .../grapheneos/camera/ui/QrFormatsDialog.kt | 27 +- .../grapheneos/camera/ui/SettingsDialog.kt | 66 +-- .../camera/ui/activities/CaptureActivity.kt | 2 +- .../camera/ui/activities/MainActivity.kt | 136 ++++-- .../camera/ui/activities/MoreSettings.kt | 4 +- .../grapheneos/camera/ui/activities/QrTile.kt | 2 +- .../grapheneos/camera/ui/seekbar/ZoomBar.kt | 6 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 21 +- .../ViewfinderOrientationHandler.kt | 2 +- .../ViewfinderChrome.kt} | 34 +- .../screen/ViewfinderController.kt} | 412 +++++++----------- .../{ => screen}/ViewfinderEffectHandler.kt | 48 +- .../ui/viewfinder/screen/ViewfinderEffects.kt | 25 ++ .../java/app/grapheneos/camera/util/Utils.kt | 2 +- .../data/media/CapturedItemRepositoryTest.kt | 19 +- 40 files changed, 770 insertions(+), 606 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/di/gallery/GalleryBindsModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/gallery/CapturedItemSession.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/gallery/mapper/VisibleCaptureMapper.kt create mode 100644 app/src/main/java/app/grapheneos/camera/domain/gallery/usecase/RevertToMediaStoreLocation.kt rename app/src/main/java/app/grapheneos/camera/ui/viewfinder/{ViewfinderEffects.kt => screen/ViewfinderChrome.kt} (56%) rename app/src/main/java/app/grapheneos/camera/{CamConfig.kt => ui/viewfinder/screen/ViewfinderController.kt} (63%) rename app/src/main/java/app/grapheneos/camera/ui/viewfinder/{ => screen}/ViewfinderEffectHandler.kt (89%) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffects.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 999e30180..e4fe362ec 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -188,18 +188,15 @@ - - - + + + - - - @@ -378,8 +375,8 @@ - - + + @@ -529,10 +526,10 @@ - - - - + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index ff87cb7d5..1a428c89c 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -133,7 +133,7 @@ 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$fun startCamera + ReturnCount:ViewfinderController.kt:ViewfinderController$fun startCamera ReturnCount:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome ReturnCount:CameraSession.kt:CameraSession$fun isLensFacingSupported: Boolean ReturnCount:CameraSession.kt:CameraSession$fun probeUnknownExtensions diff --git a/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/BottomTabLayoutRegressionTest.kt index 22cca8cea..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 ) @@ -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 19800865c..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,7 +125,7 @@ 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 } } @@ -138,7 +138,7 @@ class CameraModeTabsRegressionTest { scenario.onActivity { activity -> flingLeft(activity) - assertEquals(CameraMode.CAMERA, activity.camConfig.currentMode) + assertEquals(CameraMode.CAMERA, activity.viewfinder.currentMode) } } } diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index 2e7a20df6..02a2a74a5 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -40,7 +40,7 @@ class ModeSwitchLatencyRegressionTest { scenario.onActivity { CameraSession.clearSnapshotProbeCache() - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.VIDEO) assertEquals( "entering video mode did not probe", @@ -51,8 +51,8 @@ class ModeSwitchLatencyRegressionTest { } 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, CameraSession.snapshotProbeCount) assertEquals( @@ -78,7 +78,7 @@ class ModeSwitchLatencyRegressionTest { scenario.onActivity { CameraSession.clearSnapshotProbeCache() - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.VIDEO) } // The qualities a camera records at are listed once its preview starts streaming, @@ -93,7 +93,7 @@ class ModeSwitchLatencyRegressionTest { "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 { @@ -102,7 +102,7 @@ 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", @@ -130,7 +130,7 @@ class ModeSwitchLatencyRegressionTest { } scenario.onActivity { - it.camConfig.switchMode(CameraMode.VIDEO) + it.viewfinder.switchMode(CameraMode.VIDEO) assertNull("the bind read the zoom state", it.session.zoomState) } @@ -150,7 +150,7 @@ class ModeSwitchLatencyRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } waitUntil(scenario, "the zoom state is attached") { it.session.zoomState != null } diff --git a/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/PhotoQualityRegressionTest.kt index a831dcad7..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 @@ -40,8 +41,8 @@ class PhotoQualityRegressionTest { 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()) } } @@ -65,8 +66,8 @@ class PhotoQualityRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> 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..9ae541e74 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -11,6 +11,7 @@ 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.Dispatchers import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -44,6 +45,7 @@ class SafTreeGrantsRegressionTest { storagePrefs = InMemoryDataStore(StoragePrefs()), mediaPrefs = InMemoryDataStore(MediaPrefs()), context = context, + ioDispatcher = Dispatchers.Unconfined, ) } diff --git a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt index ba71d6411..f1362a19a 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SelfTimerRegressionTest.kt @@ -42,7 +42,7 @@ class SelfTimerRegressionTest { 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) @@ -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() @@ -101,7 +101,7 @@ class SelfTimerRegressionTest { 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() @@ -127,7 +127,7 @@ class SelfTimerRegressionTest { 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() @@ -155,7 +155,7 @@ class SelfTimerRegressionTest { 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 @@ -189,7 +189,7 @@ class SelfTimerRegressionTest { 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/VideoCapturerRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt index a7b824c32..f9860dc01 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/VideoCapturerRegressionTest.kt @@ -80,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() } @@ -103,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) } @@ -118,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 } @@ -134,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() @@ -170,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() @@ -201,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() } @@ -224,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") { @@ -276,7 +276,7 @@ class VideoCapturerRegressionTest { ActivityScenario.launch(MainActivity::class.java).use { scenario -> awaitModeTabs(scenario) - scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } waitUntil(scenario, "video use case is bound") { it.session.videoCapture != null } @@ -285,7 +285,7 @@ class VideoCapturerRegressionTest { 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 } @@ -297,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 } @@ -325,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 } @@ -356,7 +356,7 @@ class VideoCapturerRegressionTest { 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) } + scenario.onActivity { it.viewfinder.switchMode(CameraMode.VIDEO) } waitUntil(scenario, "video use case is bound") { it.session.videoCapture != null } @@ -369,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) @@ -379,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? } @@ -483,14 +483,14 @@ class VideoCapturerRegressionTest { 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/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 fd6d3b440..fdd247b6c 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,7 @@ 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 @@ -65,7 +65,7 @@ class ImageCapturer(val mActivity: MainActivity) { return } - if (!camConfig.canTakePicture) { + if (!viewfinder.canTakePicture) { mActivity.showMessage(R.string.unsupported_taking_picture_while_recording) return } @@ -77,9 +77,9 @@ class ImageCapturer(val mActivity: MainActivity) { val imageMetadata = ImageCapture.Metadata() imageMetadata.isReversedHorizontal = session.lensFacing == CameraSelector.LENS_FACING_FRONT - && camConfig.saveImageAsPreviewed + && viewfinder.saveImageAsPreviewed - if (camConfig.requireLocation) { + if (viewfinder.requireLocation) { val location = (mActivity.applicationContext as App).getLocation() if (location == null) { mActivity.showMessage(R.string.location_unavailable) @@ -96,10 +96,10 @@ class ImageCapturer(val mActivity: MainActivity) { this, mActivity.applicationContext, imageCapture.jpegQuality, - camConfig.storageLocation, + mActivity.capturedItemSession.storageLocation, imageFileFormat, imageMetadata, - camConfig.removeExifAfterCapture, + viewfinder.removeExifAfterCapture, targetThumbnailWidth = preview.width, targetThumbnailHeight = preview.height, ) @@ -123,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 @@ -170,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) @@ -178,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/VideoCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt index 45105617d..026b182eb 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -45,7 +45,7 @@ import java.util.Locale class VideoCapturer(private val mActivity: MainActivity) { - val camConfig = mActivity.camConfig + val viewfinder = mActivity.viewfinder private val session = mActivity.session @@ -107,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 { @@ -131,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) @@ -175,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 @@ -205,7 +205,7 @@ class VideoCapturer(private val mActivity: MainActivity) { afterRecordingStops() } - camConfig.mPlayer.playVRStartSound(handler) { + viewfinder.mPlayer.playVRStartSound(handler) { if (consumed) { return@playVRStartSound } @@ -226,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) { @@ -267,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() @@ -371,7 +371,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 } @@ -425,14 +425,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) } 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 index 090a17ac9..131997ef1 100644 --- 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 @@ -242,7 +242,12 @@ class CameraSession @AssistedInject constructor( // Manually switch to the other lens facing (if the default lens facing isn't // supported for the current device) - if (!isLensFacingSupported(lensFacing, extensionMode)) { + 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 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/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/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 index 9f1076b6f..894490e1c 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt +++ b/app/src/main/java/app/grapheneos/camera/domain/qr/BarcodeFormats.kt @@ -4,6 +4,7 @@ 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 @@ -18,7 +19,15 @@ class BarcodeFormats @Inject constructor( return enabledFormats } - fun load(enabledNames: Set) { + 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 }) } 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/CountDownTimerUI.kt b/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt index 9f5e818c0..5c28ebc65 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 index d614825e2..4afb192d5 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/QrFormatsDialog.kt @@ -1,10 +1,35 @@ 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 showQrFormatsDialog( +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, 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 6a0c8720e..88983d967 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -53,7 +53,7 @@ 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 @@ -178,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 } } @@ -189,20 +189,20 @@ 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) } } @@ -220,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 @@ -252,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 + viewfinder.waitForFocusLock = waitForFocusLockSwitch.isChecked if (session.cameraProvider != null) { - camConfig.startCamera(true) + viewfinder.startCamera(true) } } selfIlluminationToggle = binding.selfIlluminationSwitch selfIlluminationToggle.setOnCheckedChangeListener { _, isChecked -> - camConfig.selfIlluminate = isChecked + viewfinder.selfIlluminate = isChecked } binding.selfIlluminationSwitchContainer.setOnTouchListener { _, event -> event.setLocation(0f, 0f) @@ -366,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) @@ -376,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) @@ -496,7 +496,7 @@ 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 (session.canApplyVideoStabilization()) { @@ -517,7 +517,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : View.GONE } - timerSetting.visibility = if (camConfig.isVideoMode) { + timerSetting.visibility = if (viewfinder.isVideoMode) { View.GONE } else { View.VISIBLE @@ -528,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) { @@ -554,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) @@ -568,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)) } @@ -583,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) @@ -677,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 { @@ -767,15 +767,15 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : fun loadInitialState() { updateGridToggleUI() - updateFocusTimeout(focusTimeoutLabel(camConfig.focusTimeout)) - enableEISToggle.isChecked = camConfig.enableEIS + 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 @@ -788,7 +788,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : fun updateFlashMode() { val (icon, description) = if (session.isFlashAvailable) { - when (camConfig.flashMode) { + 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 @@ -816,10 +816,10 @@ 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 = session.isTorchOn @@ -853,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/activities/CaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/CaptureActivity.kt index 7858aeca2..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 @@ -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 8d1b0b3a7..7ed3e2e47 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 @@ -70,8 +70,8 @@ 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 @@ -83,6 +83,9 @@ import app.grapheneos.camera.data.core.model.CameraMode import app.grapheneos.camera.data.settings.repository.SettingsRepository 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 @@ -96,9 +99,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.viewfinder.ViewfinderEffectHandler +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 @@ -117,6 +122,8 @@ import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import kotlin.math.max import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @AndroidEntryPoint open class MainActivity : AppCompatActivity() { @@ -125,14 +132,23 @@ open class MainActivity : AppCompatActivity() { lateinit var cameraSessionFactory: CameraSession.Factory @Inject - lateinit var camConfigFactory: CamConfig.Factory + lateinit var viewfinderFactory: ViewfinderController.Factory @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var cameraEntryPoint: CameraEntryPoint + + @Inject + lateinit var barcodeFormats: BarcodeFormats + + @Inject + lateinit var capturedItemSession: CapturedItemSession + lateinit var session: CameraSession - lateinit var camConfig: CamConfig + lateinit var viewfinder: ViewfinderController private val application: App get() = applicationContext as App @@ -312,7 +328,7 @@ open class MainActivity : AppCompatActivity() { ) { granted -> if (granted) { shouldRestartRecording = true - camConfig.startCamera(true) + viewfinder.startCamera(true) return@registerForActivityResult } showAudioPermissionDeniedDialog { @@ -372,7 +388,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() } @@ -402,7 +418,7 @@ open class MainActivity : AppCompatActivity() { transitionShown = false mainOverlay.visibility = View.INVISIBLE - if (camConfig.isQRMode) { + if (viewfinder.isQRMode) { return } @@ -598,7 +614,10 @@ open class MainActivity : AppCompatActivity() { } 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) @@ -629,7 +648,7 @@ open class MainActivity : AppCompatActivity() { 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.") @@ -683,7 +702,7 @@ open class MainActivity : AppCompatActivity() { 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) } @@ -725,17 +744,13 @@ open class MainActivity : AppCompatActivity() { // 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() } @@ -743,7 +758,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.") } @@ -753,9 +768,22 @@ 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() @@ -767,12 +795,12 @@ open class MainActivity : AppCompatActivity() { // 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 @@ -787,14 +815,22 @@ open class MainActivity : AppCompatActivity() { val sessionHandler = ViewfinderEffectHandler(this) session = cameraSessionFactory.create(environment = sessionHandler) - camConfig = camConfigFactory.create( + viewfinder = viewfinderFactory.create( environment = sessionHandler, effects = sessionHandler, + chrome = sessionHandler, session = session, ) cameraControl = CameraControl(session) imageCapturer = ImageCapturer(this) videoCapturer = VideoCapturer(this) + + lifecycleScope.launch(Dispatchers.Main.immediate) { + capturedItemSession.prepare() + + updateThumbnail() + } + previewView.scaleType = PreviewView.ScaleType.FIT_START tabLayout.setOnTouchListener { _, motionEvent -> @@ -810,7 +846,7 @@ open class MainActivity : AppCompatActivity() { previewView.previewStreamState.observe(this) { state: StreamState -> if (state == StreamState.STREAMING) { hidePreviewTransition() - camConfig.reloadSettings() + viewfinder.reloadSettings() restartRecordingIfPermissionsWasUnavailable() } else { @@ -846,8 +882,8 @@ open class MainActivity : AppCompatActivity() { } flipCameraCircle.setOnClickListener { resetAutoSleep() - if (camConfig.isQRMode) { - camConfig.scanAllCodes = !camConfig.scanAllCodes + if (viewfinder.isQRMode) { + viewfinder.scanAllCodes = !viewfinder.scanAllCodes return@setOnClickListener } @@ -875,7 +911,7 @@ open class MainActivity : AppCompatActivity() { rotate.interpolator = LinearInterpolator() it.startAnimation(rotate) - camConfig.toggleCameraSelector() + viewfinder.toggleCameraSelector() } binding.thirdCircle.setOnClickListener { @@ -905,13 +941,13 @@ 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) { + } else if (viewfinder.isQRMode) { session.toggleTorchState() if (session.isTorchOn) { setCaptureButtonIcon(R.drawable.torch_on_button, R.string.turn_torch_off) @@ -935,7 +971,7 @@ open class MainActivity : AppCompatActivity() { exposureBar.setMainActivity(this) settingsIcon.setOnClickListener { - if (!camConfig.isQRMode) { + if (!viewfinder.isQRMode) { settingsDialog.show() } } @@ -1057,7 +1093,11 @@ open class MainActivity : AppCompatActivity() { ) binding.moreOptions.setOnClickListener { - camConfig.showMoreOptionsForQR() + showMoreQrFormatOptions( + activity = this, + barcodeFormats = barcodeFormats, + onApplied = { session.refreshQrHints() }, + ) } qrToggle.mActivity = this @@ -1072,7 +1112,12 @@ open class MainActivity : AppCompatActivity() { azToggle.mActivity = this azToggle.key = BarcodeFormat.AZTEC.name - camConfig.loadSettings() + viewfinder.loadSettings() + + barcodeFormats.load() + selectBarcodeFormatToggles() + session.refreshQrHints() + settingsDialog.loadInitialState() muteToggle.setOnClickListener { @@ -1137,7 +1182,7 @@ open class MainActivity : AppCompatActivity() { // 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 @@ -1151,15 +1196,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 @@ -1178,7 +1223,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 } @@ -1192,7 +1237,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 @@ -1328,7 +1373,7 @@ open class MainActivity : AppCompatActivity() { builder.setOnDismissListener { isQRDialogShowing = false - camConfig.startCamera(true) + viewfinder.startCamera(true) } session.cameraProvider?.unbindAll() @@ -1429,7 +1474,7 @@ open class MainActivity : AppCompatActivity() { session.preview?.targetRotation = previewView.display?.rotation ?: Surface.ROTATION_0 session.camera?.cameraInfo?.let { - previewView.applyPreviewRatio(camConfig.aspectRatio, it) + previewView.applyPreviewRatio(viewfinder.aspectRatio, it) } rootView.post { sensorNotifier?.notifyListeners() } @@ -1477,10 +1522,11 @@ open class MainActivity : AppCompatActivity() { SensorOrientationChangeNotifier.clearInstance() thumbnailLoaderExecutor.shutdownNow() frameCopyThread?.quitSafely() - camConfig.onDestroy() + viewfinder.onDestroy() + capturedItemSession.close() } - fun locationCamConfigChanged(required: Boolean) { + fun onRequireLocationChanged(required: Boolean) { if (required) { requestLocation() } else { @@ -1493,7 +1539,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()) } } @@ -1505,7 +1551,7 @@ open class MainActivity : AppCompatActivity() { if (!application.shouldAskForLocationPermission()) { requestLocation() } else { - camConfig.requireLocation = false + viewfinder.requireLocation = false } } @@ -1533,7 +1579,7 @@ open class MainActivity : AppCompatActivity() { it.setOnDismissListener { if (!hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { - camConfig.requireLocation = false + viewfinder.requireLocation = false } } }.showIgnoringShortEdgeMode() @@ -1578,7 +1624,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 @@ -1614,7 +1660,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 } 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 79946ccf9..35de2daea 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 @@ -333,7 +333,7 @@ open class MoreSettings : // 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) @@ -387,7 +387,7 @@ open class MoreSettings : fun start(caller: MainActivity) { val flavor = if (caller is SecureActivity) MoreSettingsSecure::class else MoreSettings::class Intent(caller, flavor.java).let { - it.putExtra(INTENT_EXTRA_IN_CAPTURE_MODE, caller.camConfig.isInCaptureMode) + it.putExtra(INTENT_EXTRA_IN_CAPTURE_MODE, caller.cameraEntryPoint.isCaptureSession) it.putExtra(INTENT_EXTRA_ZSL_SUPPORTED, caller.session.isZslSupported) caller.startActivity(it) 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/ZoomBar.kt b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ZoomBar.kt index 656ea2fe4..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,10 +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 { @@ -47,13 +47,13 @@ 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 } 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 index 79e914bae..d2a252d37 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -8,6 +8,7 @@ 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 @@ -42,7 +43,7 @@ internal class ViewfinderGestureHandler( return true } - if (activity.camConfig.isQRMode) { + if (activity.viewfinder.isQRMode) { return false } @@ -56,15 +57,15 @@ internal class ViewfinderGestureHandler( val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint) - if (!activity.camConfig.isVideoMode) { - activity.camConfig.mPlayer.playFocusStartSound() + if (!activity.viewfinder.isVideoMode) { + activity.viewfinder.mPlayer.playFocusStartSound() } - if (activity.camConfig.focusTimeout == 0L) { + if (activity.viewfinder.focusTimeout == 0L) { focusBuilder.disableAutoCancel() } else { focusBuilder.setAutoCancelDuration( - activity.camConfig.focusTimeout, + activity.viewfinder.focusTimeout, TimeUnit.SECONDS, ) } @@ -176,14 +177,18 @@ internal class ViewfinderGestureHandler( if (activity.settingsDialog.isShowing) return when { - !activity.camConfig.isQRMode -> { + !activity.viewfinder.isQRMode -> { if (activity.settingsIcon.isEnabled) { activity.settingsIcon.performClick() } } - !activity.camConfig.scanAllCodes -> { - activity.camConfig.showMoreOptionsForQR() + !activity.viewfinder.scanAllCodes -> { + showMoreQrFormatOptions( + activity = activity, + barcodeFormats = activity.barcodeFormats, + onApplied = { activity.session.refreshQrHints() }, + ) } } } 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 index 1a54b535c..6a8981e2e 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderOrientationHandler.kt @@ -113,7 +113,7 @@ internal class ViewfinderOrientationHandler( zDegrees: Float, ) { // If we are in photo mode and the countdown timer isn't running - if (activity.camConfig.isQRMode || activity.camConfig.isVideoMode || + if (activity.viewfinder.isQRMode || activity.viewfinder.isVideoMode || activity.cdTimer.isRunning ) { return diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt similarity index 56% rename from app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt rename to app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt index 3afd84433..6b5066ed7 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffects.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderChrome.kt @@ -1,16 +1,10 @@ -package app.grapheneos.camera.ui.viewfinder +package app.grapheneos.camera.ui.viewfinder.screen -import androidx.annotation.StringRes import androidx.camera.core.CameraInfo import androidx.camera.core.ExposureState import app.grapheneos.camera.data.core.model.CameraMode -import com.google.zxing.BarcodeFormat -interface ViewfinderEffects { - - fun showMessage(@StringRes message: Int) - - fun showMessage(message: String) +interface ViewfinderChrome { fun applyModeChrome(mode: CameraMode, isVideoMode: Boolean, scanAllCodes: Boolean) @@ -22,10 +16,6 @@ interface ViewfinderEffects { fun onPreviewBound(aspectRatio: Int, cameraInfo: CameraInfo) - fun updateLastFrame() - - fun flashPreview(selfIlluminate: Boolean) - fun updateZoomThumb(shouldShowPanel: Boolean) fun applyExposureState(exposureState: ExposureState) @@ -36,16 +26,6 @@ interface ViewfinderEffects { fun updateGyroscopeIndicator(inPhotoMode: Boolean) - fun forceUpdateOrientationSensor() - - fun startFocusTimer() - - fun cancelFocusTimer() - - fun cancelPendingCapture() - - fun selectBarcodeFormatToggles(formats: List) - fun onFlashModeChanged() fun onIncludeAudioChanged(enabled: Boolean) @@ -54,19 +34,11 @@ interface ViewfinderEffects { fun onSelfIlluminationChanged(enabled: Boolean) - fun locationCamConfigChanged(required: Boolean) + fun onRequireLocationChanged(required: Boolean) fun reloadVideoQualities() fun showOnlyRelevantSettings() fun resetTorchToggle() - - fun showBarcodeFormatPicker( - optionNames: List, - initialValues: List, - onConfirm: (List) -> Unit, - ) - - fun showStorageLocationNotFound() } diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt similarity index 63% rename from app/src/main/java/app/grapheneos/camera/CamConfig.kt rename to app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt index 22984b106..8b665df98 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderController.kt @@ -1,26 +1,18 @@ -package app.grapheneos.camera +package app.grapheneos.camera.ui.viewfinder.screen -import android.annotation.SuppressLint import android.util.Log import androidx.annotation.VisibleForTesting import androidx.camera.core.AspectRatio -import androidx.camera.core.Camera import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture -import androidx.camera.core.Preview -import androidx.camera.core.ZoomState import androidx.camera.core.featuregroup.GroupableFeature -import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.Quality -import androidx.camera.video.Recorder -import androidx.camera.video.VideoCapture +import app.grapheneos.camera.R 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.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 @@ -30,10 +22,7 @@ import app.grapheneos.camera.di.core.MainImmediateDispatcher 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.qr.BarcodeFormats -import app.grapheneos.camera.ui.videoQualityTitle -import app.grapheneos.camera.ui.viewfinder.ViewfinderEffects -import com.google.zxing.BarcodeFormat +import app.grapheneos.camera.domain.gallery.usecase.RevertToMediaStoreLocation import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -47,36 +36,19 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -@SuppressLint("UnsafeOptInUsageError") -class CamConfig @AssistedInject constructor( +class ViewfinderController @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, @Assisted private val effects: ViewfinderEffects, + @Assisted private val chrome: ViewfinderChrome, @Assisted private val session: CameraSession, private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, - private val capturedItemRepository: CapturedItemRepository, private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, - private val barcodeFormats: BarcodeFormats, + private val revertToMediaStoreLocation: RevertToMediaStoreLocation, @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, ) : CameraSession.Listener { - override fun onZoomStateChanged() { - effects.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) - } - @set:VisibleForTesting var mPlayer = environment.createTunePlayer() @@ -84,51 +56,21 @@ class CamConfig @AssistedInject constructor( settingsRepository.settings.first() } - private var currentStorageLocation: String = runBlocking { - capturedItemRepository.storageLocation.first() - } - private var modeSettings: ModeSettings = ModeSettings() private val preferencesScope = CoroutineScope(mainDispatcher) - var lastCapturedItem: CapturedItem? = null - - init { - session.listener = this - - preferencesScope.launch { - settingsRepository.settings.collect { settings = it } - } - - preferencesScope.launch { - capturedItemRepository.storageLocation.collect { currentStorageLocation = it } - } - - if (!entryPoint.isSecureSession) { - try { - runBlocking { - capturedItemRepository.migrateStoredCaptures(::updateLastCapturedItem) - capturedItemRepository.releaseUntrackedSafTrees() - } - } catch (e: IOException) { - Log.e(TAG, "unable to migrate the stored captures", e) - } - - fetchLastCapturedItem() - } - } - var currentMode: CameraMode = DEFAULT_CAMERA_MODE private set - var isQRMode = false - private set + val isQRMode: Boolean + get() { + return currentMode.isQr + } - var isVideoMode = false - private set + val isVideoMode: Boolean get() { - return field || entryPoint.requiresVideoModeOnly + return currentMode.isVideo || entryPoint.requiresVideoModeOnly } val canTakePicture: Boolean @@ -141,9 +83,6 @@ class CamConfig @AssistedInject constructor( return !(isQRMode || isVideoMode) } - val isInCaptureMode: Boolean - get() = entryPoint.isCaptureSession - var gridType: GridType by setting( read = { it.gridType }, write = { current, value -> current.copy(gridType = value) }, @@ -208,7 +147,7 @@ class CamConfig @AssistedInject constructor( write = { current, value -> current.copy(scanAllCodes = value) }, onChanged = { value -> if (isQRMode) { - effects.applyScanAllCodesChrome(value) + chrome.applyScanAllCodesChrome(value) } session.refreshQrHints() @@ -218,7 +157,7 @@ class CamConfig @AssistedInject constructor( var includeAudio: Boolean by setting( read = { it.includeAudio }, write = { current, value -> current.copy(includeAudio = value) }, - onChanged = { value -> effects.onIncludeAudioChanged(value) }, + onChanged = { value -> chrome.onIncludeAudioChanged(value) }, ) var flashMode: Int = SettingsDefaults.FLASH_MODE @@ -237,7 +176,7 @@ class CamConfig @AssistedInject constructor( // the preference back here would resurrect the very stale "on" the coercion exists to drop. var requireLocation: Boolean = false set(value) { - effects.locationCamConfigChanged(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. @@ -245,7 +184,7 @@ class CamConfig @AssistedInject constructor( settingsRepository.setGeoTagging(value) }?.let { modeSettings = it } - effects.onGeoTaggingChanged(value) + chrome.onGeoTaggingChanged(value) field = value } @@ -260,37 +199,35 @@ class CamConfig @AssistedInject constructor( settingsRepository.setSelfIllumination(value) }?.let { modeSettings = it } - effects.onSelfIlluminationChanged(value) + chrome.onSelfIlluminationChanged(value) } - var storageLocation: String - get() = currentStorageLocation - set(value) { - runBlocking { - capturedItemRepository.setStorageLocation(value) - capturedItemRepository.releaseUntrackedSafTrees() - } + init { + session.listener = this - currentStorageLocation = value + preferencesScope.launch { + settingsRepository.settings.collect { settings = it } } + } - val allowedFormats: List - get() = barcodeFormats.enabled + override fun onZoomStateChanged() { + chrome.updateZoomThumb(shouldShowPanel = true) + } - fun onDestroy() { - preferencesScope.cancel() + override fun onCameraProviderUnavailable() { + effects.showMessage(R.string.camera_provider_init_failure) } - 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 && entryPoint.isVideoOnlySession + override fun onExtensionsUnavailable() { + effects.showMessage(R.string.extensions_manager_init_failure) + } - lastCapturedItem = if (skip) null else item + override fun onProviderReady(forced: Boolean) { + startCamera(forced = forced) + } + + fun onDestroy() { + preferencesScope.cancel() } fun setFlashMode(value: Int) { @@ -301,48 +238,15 @@ class CamConfig @AssistedInject constructor( applyFlashMode(value) } - private fun applyFlashMode(value: Int) { - flashMode = value - session.imageCapture?.flashMode = value - effects.onFlashModeChanged() - } - fun shouldShowGyroscope(): Boolean { return isInPhotoMode && settings.gyroscopeSuggestions } - 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) - } - } - - fun setQRScanningFor(format: String, selected: Boolean) { - if (!barcodeFormats.setEnabled(formatName = format, enabled = selected)) { - effects.showMessage(R.string.no_barcode_selected) - } - - session.refreshQrHints() - } - - private fun slotCurrentMode() { - modeSettings = runBlocking { - settingsRepository.selectMode( - mode = currentMode, - isFrontFacing = session.lensFacing == CameraSelector.LENS_FACING_FRONT, - ) - } - } - fun reloadSettings() { slotCurrentMode() if (isVideoMode) { - effects.reloadVideoQualities() + chrome.reloadVideoQualities() } applyFlashMode(modeSettings.flashMode) @@ -356,30 +260,26 @@ class CamConfig @AssistedInject constructor( selfIlluminate = modeSettings.selfIllumination - effects.showOnlyRelevantSettings() + chrome.showOnlyRelevantSettings() } fun loadSettings() { includeAudio = settings.includeAudio - - barcodeFormats.load(settings.enabledBarcodeFormats) - - effects.selectBarcodeFormatToggles(allowedFormats) - - session.refreshQrHints() } fun toggleFlashMode() { - if (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 + 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) } - setFlashMode(next) - } else { - effects.showMessage(R.string.flash_unavailable_in_selected_mode) + else -> effects.showMessage(R.string.flash_unavailable_in_selected_mode) } } @@ -388,31 +288,39 @@ class CamConfig @AssistedInject constructor( 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 = - if (session.lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK - } + 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) - // 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 (session.isLensFacingSupported(session.lensFacing, currentMode.extensionMode)) { - startCamera(true) - } else { // Else revert back to the old facing (while displaying an error message // to the user) - session.lensFacing = if (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 + 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 + } + } } } } @@ -434,12 +342,7 @@ class CamConfig @AssistedInject constructor( selected = selected, ) ?: return - effects.showMessage( - environment.sessionContext.getString( - R.string.quality_unsupported, - videoQualityTitle(environment.sessionContext, droppedQuality), - ) - ) + effects.showVideoQualityUnsupported(droppedQuality) } fun initializeCamera(forced: Boolean = false) { @@ -453,7 +356,7 @@ class CamConfig @AssistedInject constructor( // Cancel any pending capture requests effects.cancelPendingCapture() - effects.hideExposurePanel() + chrome.hideExposurePanel() slotCurrentMode() // Before the builder below reads it: the mode just slotted may store a different flash mode @@ -469,11 +372,15 @@ class CamConfig @AssistedInject constructor( // (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 (!session.isLensFacingSupported(session.lensFacing, currentMode.extensionMode)) { - session.lensFacing = if (session.lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK + 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 } } @@ -493,7 +400,7 @@ class CamConfig @AssistedInject constructor( } if (isVideoMode) { - effects.setMicMutedIconVisible(!includeAudio) + chrome.setMicMutedIconVisible(!includeAudio) } effects.forceUpdateOrientationSensor() @@ -534,75 +441,16 @@ class CamConfig @AssistedInject constructor( BindOutcome.BOUND -> announceBind() } } + fun onStorageLocationNotFound() { + runBlocking { revertToMediaStoreLocation() } - private fun qrLensFacing(): Int { - if (session.isLensFacingSupported( - lensFacing = CameraSelector.LENS_FACING_BACK, - extensionMode = currentMode.extensionMode, - ) - ) { - return CameraSelector.LENS_FACING_BACK - } - - effects.showMessage(R.string.qr_rear_camera_unavailable) - - return CameraSelector.LENS_FACING_FRONT - } - - private fun announceBind() { - loadTabs() - - session.reattachZoomState() - - effects.updateZoomThumb(shouldShowPanel = false) - - session.camera?.cameraInfo?.exposureState?.let { effects.applyExposureState(it) } - - effects.resetTorchToggle() - - session.camera?.cameraInfo?.let { effects.onPreviewBound(aspectRatio, it) } - - effects.updateGyroscopeIndicator(isInPhotoMode) + effects.showStorageLocationNotFound() } fun snapPreview() { effects.flashPreview(selfIlluminate) } - // 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 resolveAvailableModes( - allowsQrScanning = entryPoint.allowsQrScanning, - extensionsAvailable = session.extensionsAvailable, - ) - } - - private fun loadTabs() { - if (!entryPoint.showsCameraModeTabs) { - 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. - session.probeUnknownExtensions(onRestart = ::loadTabs, onSettled = ::buildTabs) - } - - private fun buildTabs() { - effects.setCameraModeTabs( - modes = availableModes(), - currentMode = currentMode, - ) - } - fun switchMode(mode: CameraMode) { if (currentMode == mode) { return @@ -612,11 +460,7 @@ class CamConfig @AssistedInject constructor( effects.cancelFocusTimer() - isQRMode = mode == CameraMode.QR_SCAN - - isVideoMode = mode == CameraMode.VIDEO - - effects.applyModeChrome( + chrome.applyModeChrome( mode = mode, isVideoMode = isVideoMode, scanAllCodes = scanAllCodes, @@ -629,36 +473,75 @@ class CamConfig @AssistedInject constructor( // 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) { - effects.goToModeTab(currentMode) + chrome.goToModeTab(currentMode) } } - fun showMoreOptionsForQR() { - val optionNames = barcodeFormats.uncommonNames() + private fun applyFlashMode(value: Int) { + flashMode = value + session.imageCapture?.flashMode = value + chrome.onFlashModeChanged() + } + + private fun slotCurrentMode() { + modeSettings = runBlocking { + settingsRepository.selectMode( + mode = currentMode, + isFrontFacing = session.lensFacing == CameraSelector.LENS_FACING_FRONT, + ) + } + } - effects.showBarcodeFormatPicker( - optionNames = optionNames, - initialValues = optionNames.map { barcodeFormats.isEnabled(it) }, - onConfirm = { values -> applyBarcodeFormats(optionNames, values) }, + 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 applyBarcodeFormats(optionNames: List, values: List) { - val selection = optionNames.withIndex().associate { (index, name) -> name to values[index] } + 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) } - if (!barcodeFormats.apply(selection)) { - effects.showMessage(R.string.no_barcode_selected) + chrome.updateGyroscopeIndicator(isInPhotoMode) + } + + private fun loadTabs() { + if (!entryPoint.showsCameraModeTabs) { return } - session.refreshQrHints() + session.probeUnknownExtensions( + onRestart = ::loadTabs, + onSettled = ::buildTabs, + ) } - fun onStorageLocationNotFound() { - // Reverting back to DEFAULT_MEDIA_STORE_CAPTURE_PATH - storageLocation = CapturedItemRepository.MEDIA_STORE_LOCATION - - effects.showStorageLocationNotFound() + private fun buildTabs() { + chrome.setCameraModeTabs( + modes = resolveAvailableModes( + allowsQrScanning = entryPoint.allowsQrScanning, + extensionsAvailable = session.extensionsAvailable, + ), + currentMode = currentMode, + ) } private fun setting( @@ -684,12 +567,13 @@ class CamConfig @AssistedInject constructor( fun create( environment: CameraSessionEnvironment, effects: ViewfinderEffects, + chrome: ViewfinderChrome, session: CameraSession, - ): CamConfig + ): ViewfinderController } companion object { - private const val TAG = "CamConfig" + private const val TAG = "ViewfinderController" val DEFAULT_CAMERA_MODE = CameraMode.CAMERA } diff --git a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt similarity index 89% rename from app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt rename to app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt index 83a378c2e..8deca787b 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderEffectHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderEffectHandler.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.ui.viewfinder +package app.grapheneos.camera.ui.viewfinder.screen import android.content.Context import android.os.Build @@ -10,6 +10,7 @@ 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 @@ -20,15 +21,15 @@ 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.showQrFormatsDialog import app.grapheneos.camera.ui.showStorageLocationNotFoundDialog -import com.google.zxing.BarcodeFormat +import app.grapheneos.camera.ui.videoQualityTitle import java.util.concurrent.Executor internal class ViewfinderEffectHandler( private val activity: MainActivity, ) : CameraSessionEnvironment, - ViewfinderEffects { + ViewfinderEffects, + ViewfinderChrome { override val sessionContext: Context get() { @@ -73,8 +74,13 @@ internal class ViewfinderEffectHandler( activity.showMessage(message) } - override fun showMessage(message: String) { - activity.showMessage(message) + override fun showVideoQualityUnsupported(quality: Quality) { + activity.showMessage( + activity.getString( + R.string.quality_unsupported, + videoQualityTitle(activity, quality), + ) + ) } override fun updateLastFrame() { @@ -93,8 +99,8 @@ internal class ViewfinderEffectHandler( activity.cancelFocusTimer() } - override fun locationCamConfigChanged(required: Boolean) { - activity.locationCamConfigChanged(required) + override fun onRequireLocationChanged(required: Boolean) { + activity.onRequireLocationChanged(required) } override fun shouldAskForLocationPermission(): Boolean { @@ -109,19 +115,6 @@ internal class ViewfinderEffectHandler( return QRAnalyzer(activity) } - override fun showBarcodeFormatPicker( - optionNames: List, - initialValues: List, - onConfirm: (List) -> Unit, - ) { - showQrFormatsDialog( - activity = activity, - optionNames = optionNames, - initialValues = initialValues, - onConfirm = onConfirm, - ) - } - override fun showStorageLocationNotFound() { showStorageLocationNotFoundDialog(activity) } @@ -205,19 +198,6 @@ internal class ViewfinderEffectHandler( activity.settingsDialog.torchToggle.isChecked = false } - override fun selectBarcodeFormatToggles(formats: List) { - val toggles = mapOf( - BarcodeFormat.QR_CODE to activity.qrToggle, - BarcodeFormat.AZTEC to activity.azToggle, - BarcodeFormat.PDF_417 to activity.cBToggle, - BarcodeFormat.DATA_MATRIX to activity.dmToggle, - ) - - formats.forEach { format -> - toggles[format]?.isSelected = true - } - } - override fun applyModeChrome( mode: CameraMode, isVideoMode: Boolean, 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/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/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt b/app/src/test/java/app/grapheneos/camera/data/media/CapturedItemRepositoryTest.kt index b9bd5985f..bae9ce3c5 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,6 +26,7 @@ import app.grapheneos.camera.data.media.store.mediaPrefsSerializer import java.io.File import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel @@ -72,6 +73,7 @@ class CapturedItemRepositoryTest { storagePrefs = session, mediaPrefs = media, context = context, + ioDispatcher = Dispatchers.Unconfined, ) } @@ -108,7 +110,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 +118,7 @@ class CapturedItemRepositoryTest { @Test fun lastCapturedItem_freshInstall_returnsNull() { - assertNull(runBlocking { repository().lastCapturedItem() }) + assertNull(runBlocking { repository().lastCapturedItem.first() }) } @Test @@ -137,7 +139,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 +209,7 @@ class CapturedItemRepositoryTest { fun migrateStoredCaptures_legacyUris_becomeTrackedTreesOnce() { writeLegacyMediaUris(listOf("treeA", "treeB")) - runBlocking { repository().migrateStoredCaptures { } } + runBlocking { repository().migrateStoredCaptures() } assertNull(stored().legacyMediaUris) assertEquals( @@ -218,7 +220,7 @@ class CapturedItemRepositoryTest { runBlocking { repository().setStorageLocation(treeUri("treeC").toString()) repository().setStorageLocation(treeUri("treeD").toString()) - repository().migrateStoredCaptures { } + repository().migrateStoredCaptures() } assertEquals( @@ -233,7 +235,7 @@ class CapturedItemRepositoryTest { runBlocking { storagePrefs.updateData { it.copy(storageLocation = location) } } writeLegacyMediaUris(listOf("treeA")) - runBlocking { repository().migrateStoredCaptures { } } + runBlocking { repository().migrateStoredCaptures() } assertEquals(emptyList(), previousSafTrees()) } @@ -244,7 +246,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 +266,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( From 83ef58b9bb3efcc6ddf5c6e7e7c5f287e56dab8e Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Tue, 8 Sep 2026 18:53:27 +0200 Subject: [PATCH 19/23] Point guidelines at decomposed viewfinder --- AGENTS.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) 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. From 6c4764fde8d2f323a37f9e7862551271dffd2a7a Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 9 Sep 2026 11:51:05 +0200 Subject: [PATCH 20/23] Run tests on test dispatcher --- app/build.gradle.kts | 2 ++ .../camera/SafTreeGrantsRegressionTest.kt | 6 ++-- .../data/media/CapturedItemRepositoryTest.kt | 6 ++-- gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 28 +++++++++++++++++++ 5 files changed, 39 insertions(+), 4 deletions(-) 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/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt index 9ae541e74..a356f93d4 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/SafTreeGrantsRegressionTest.kt @@ -11,8 +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.Dispatchers +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 @@ -23,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 { @@ -45,7 +47,7 @@ class SafTreeGrantsRegressionTest { storagePrefs = InMemoryDataStore(StoragePrefs()), mediaPrefs = InMemoryDataStore(MediaPrefs()), context = context, - ioDispatcher = Dispatchers.Unconfined, + ioDispatcher = UnconfinedTestDispatcher(), ) } 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 bae9ce3c5..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,12 +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.Dispatchers +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 @@ -42,6 +43,7 @@ import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class CapturedItemRepositoryTest { @@ -73,7 +75,7 @@ class CapturedItemRepositoryTest { storagePrefs = session, mediaPrefs = media, context = context, - ioDispatcher = Dispatchers.Unconfined, + ioDispatcher = UnconfinedTestDispatcher(), ) } 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 4b5881d493b50ae035b01d80dae31acb7a0f248b Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 9 Sep 2026 13:35:58 +0200 Subject: [PATCH 21/23] Attach viewfinder to its Activity --- app/config/ktlint/baseline.xml | 12 +-- .../camera/capturer/ImageCapturer.kt | 2 +- .../camera/capturer/VideoCapturer.kt | 11 ++- .../grapheneos/camera/ui/CountDownTimerUI.kt | 4 +- .../camera/ui/activities/MainActivity.kt | 8 +- .../ui/viewfinder/ViewfinderGestureHandler.kt | 2 +- .../viewfinder/screen/ViewfinderController.kt | 92 ++++++++++++++----- 7 files changed, 89 insertions(+), 42 deletions(-) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index e4fe362ec..bba93b3ab 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -301,8 +301,8 @@ - - + + @@ -526,10 +526,10 @@ - - - - + + + + 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 fdd247b6c..404a3bb3a 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageCapturer.kt @@ -123,7 +123,7 @@ class ImageCapturer(val mActivity: MainActivity) { unfadeCaptureButton() currentImageSaver = null - viewfinder.mPlayer.playShutterSound() + viewfinder.mPlayer?.playShutterSound() viewfinder.snapPreview() mActivity.previewLoader.visibility = View.VISIBLE 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 026b182eb..edce6e1d9 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt @@ -205,9 +205,9 @@ class VideoCapturer(private val mActivity: MainActivity) { afterRecordingStops() } - viewfinder.mPlayer.playVRStartSound(handler) { + val onStartSoundPlayed = onStartSoundPlayed@{ if (consumed) { - return@playVRStartSound + return@onStartSoundPlayed } consumed = true @@ -226,7 +226,7 @@ class VideoCapturer(private val mActivity: MainActivity) { if (event is VideoRecordEvent.Finalize) { afterRecordingStops() - viewfinder.mPlayer.playVRStopSound() + viewfinder.mPlayer?.playVRStopSound() if (event.hasError()) { when (event.error) { @@ -298,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 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 5c28ebc65..0efecec73 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/CountDownTimerUI.kt @@ -68,9 +68,9 @@ class CountDownTimerUI @JvmOverloads constructor( text = pendingS.toString() if (text == "1") { - viewfinder.mPlayer.playTimerFinalSSound() + viewfinder.mPlayer?.playTimerFinalSSound() } else { - viewfinder.mPlayer.playTimerIncrementSound() + viewfinder.mPlayer?.playTimerIncrementSound() } } 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 7ed3e2e47..6fec795b9 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 @@ -132,7 +132,7 @@ open class MainActivity : AppCompatActivity() { lateinit var cameraSessionFactory: CameraSession.Factory @Inject - lateinit var viewfinderFactory: ViewfinderController.Factory + lateinit var viewfinder: ViewfinderController @Inject lateinit var settingsRepository: SettingsRepository @@ -148,8 +148,6 @@ open class MainActivity : AppCompatActivity() { lateinit var session: CameraSession - lateinit var viewfinder: ViewfinderController - private val application: App get() = applicationContext as App @@ -815,7 +813,7 @@ open class MainActivity : AppCompatActivity() { val sessionHandler = ViewfinderEffectHandler(this) session = cameraSessionFactory.create(environment = sessionHandler) - viewfinder = viewfinderFactory.create( + viewfinder.attach( environment = sessionHandler, effects = sessionHandler, chrome = sessionHandler, @@ -1522,7 +1520,7 @@ open class MainActivity : AppCompatActivity() { SensorOrientationChangeNotifier.clearInstance() thumbnailLoaderExecutor.shutdownNow() frameCopyThread?.quitSafely() - viewfinder.onDestroy() + viewfinder.detach() capturedItemSession.close() } 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 index d2a252d37..74544b788 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/viewfinder/ViewfinderGestureHandler.kt @@ -58,7 +58,7 @@ internal class ViewfinderGestureHandler( val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint) if (!activity.viewfinder.isVideoMode) { - activity.viewfinder.mPlayer.playFocusStartSound() + activity.viewfinder.mPlayer?.playFocusStartSound() } if (activity.viewfinder.focusTimeout == 0L) { 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 index 8b665df98..97e0001a5 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -23,10 +24,9 @@ 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.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +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.CoroutineDispatcher @@ -36,11 +36,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -class ViewfinderController @AssistedInject constructor( - @Assisted private val environment: CameraSessionEnvironment, - @Assisted private val effects: ViewfinderEffects, - @Assisted private val chrome: ViewfinderChrome, - @Assisted private val session: CameraSession, +@ActivityScoped +class ViewfinderController @Inject constructor( private val entryPoint: CameraEntryPoint, private val settingsRepository: SettingsRepository, private val resolveAvailableModes: ResolveAvailableModes, @@ -49,8 +46,37 @@ class ViewfinderController @AssistedInject constructor( @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, ) : CameraSession.Listener { + private var attachment: Attachment? = null + @set:VisibleForTesting - var mPlayer = environment.createTunePlayer() + 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 var settings: CameraSettings = runBlocking { settingsRepository.settings.first() @@ -203,13 +229,38 @@ class ViewfinderController @AssistedInject constructor( } init { - session.listener = this - preferencesScope.launch { settingsRepository.settings.collect { settings = it } } } + 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 + + preferencesScope.cancel() + } + override fun onZoomStateChanged() { chrome.updateZoomThumb(shouldShowPanel = true) } @@ -226,10 +277,6 @@ class ViewfinderController @AssistedInject constructor( startCamera(forced = forced) } - fun onDestroy() { - preferencesScope.cancel() - } - fun setFlashMode(value: Int) { runBlocking { settingsRepository.setFlashMode(value) @@ -562,15 +609,12 @@ class ViewfinderController @AssistedInject constructor( } } - @AssistedFactory - interface Factory { - fun create( - environment: CameraSessionEnvironment, - effects: ViewfinderEffects, - chrome: ViewfinderChrome, - session: CameraSession, - ): ViewfinderController - } + private class Attachment( + val environment: CameraSessionEnvironment, + val effects: ViewfinderEffects, + val chrome: ViewfinderChrome, + val session: CameraSession, + ) companion object { private const val TAG = "ViewfinderController" From 785cdc8159ece700cc8ac490749c30ae7072e8d4 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Wed, 9 Sep 2026 14:57:15 +0200 Subject: [PATCH 22/23] Read setting as soon as another screen writes it --- app/config/ktlint/baseline.xml | 2 +- .../camera/SecurePrefsIsolationTest.kt | 12 +- .../camera/data/settings/model/ModeSlot.kt | 8 ++ .../settings/repository/SettingsRepository.kt | 84 ++++++------ .../preferences/PreferencesProvidesModule.kt | 21 +++ .../preferences/SecureSessionPreferences.kt | 11 ++ .../camera/di/settings/SettingsBindsModule.kt | 20 --- .../di/settings/SettingsProvidesModule.kt | 26 ++++ .../di/settings/SettingsRepositoryFactory.kt | 28 ++++ .../camera/ui/activities/MoreSettings.kt | 10 +- .../viewfinder/screen/ViewfinderController.kt | 67 ++++------ .../data/settings/SettingsRepositoryTest.kt | 90 ++++++------- .../screen/ViewfinderControllerTest.kt | 126 ++++++++++++++++++ 13 files changed, 346 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/data/settings/model/ModeSlot.kt delete mode 100644 app/src/main/java/app/grapheneos/camera/di/settings/SettingsBindsModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/settings/SettingsProvidesModule.kt create mode 100644 app/src/main/java/app/grapheneos/camera/di/settings/SettingsRepositoryFactory.kt create mode 100644 app/src/test/java/app/grapheneos/camera/ui/viewfinder/screen/ViewfinderControllerTest.kt diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index bba93b3ab..743d0717d 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -532,7 +532,7 @@ - + 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/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/preferences/PreferencesProvidesModule.kt b/app/src/main/java/app/grapheneos/camera/di/preferences/PreferencesProvidesModule.kt index f17db6d99..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 @@ -3,8 +3,10 @@ package app.grapheneos.camera.di.preferences import android.content.Context import androidx.datastore.core.DataStore 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 @@ -31,6 +33,25 @@ internal class PreferencesProvidesModule { } } + @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 + } + } + @Provides @ActivityScoped fun provideStoragePrefs( 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 index 3a8a14ec8..3984ceea0 100644 --- a/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt +++ b/app/src/main/java/app/grapheneos/camera/di/preferences/SecureSessionPreferences.kt @@ -3,6 +3,7 @@ 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 @@ -14,12 +15,21 @@ 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 } } @@ -35,6 +45,7 @@ internal class SecureSessionPreferences @Inject constructor() { openActivities = 0 settings = null storage = null + sessionSettingsRepository = null } } 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/ui/activities/MoreSettings.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt index 35de2daea..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 @@ -47,8 +47,6 @@ open class MoreSettings : private var isZslSupported = false - private var settings: CameraSettings = CameraSettings() - private var storageLocation: String = CapturedItemRepository.MEDIA_STORE_LOCATION private lateinit var binding: MoreSettingsBinding @@ -63,6 +61,11 @@ open class MoreSettings : private lateinit var pQField: EditText + private val settings: CameraSettings + get() { + return settingsRepository.settings.value + } + private val dirPickerHandler = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { @@ -93,7 +96,7 @@ open class MoreSettings : } private fun updateSettings(transform: (CameraSettings) -> CameraSettings) { - settings = runBlocking { settingsRepository.update(transform) } + runBlocking { settingsRepository.update(transform) } } private fun setStorageLocation(location: String) { @@ -111,7 +114,6 @@ open class MoreSettings : isInCaptureMode = intent.getBooleanExtra(INTENT_EXTRA_IN_CAPTURE_MODE, false) isZslSupported = intent.getBooleanExtra(INTENT_EXTRA_ZSL_SUPPORTED, false) - settings = runBlocking { settingsRepository.settings.first() } storageLocation = runBlocking { capturedItemRepository.storageLocation.first() } binding = MoreSettingsBinding.inflate(layoutInflater) 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 index 97e0001a5..5186cabc5 100644 --- 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 @@ -17,9 +17,9 @@ 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.di.core.MainImmediateDispatcher import app.grapheneos.camera.domain.camera.model.CameraEntryPoint import app.grapheneos.camera.domain.camera.usecase.ResolveAvailableModes import app.grapheneos.camera.domain.camera.usecase.ResolveDroppedVideoQuality @@ -29,11 +29,6 @@ import java.io.IOException import javax.inject.Inject import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -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 @ActivityScoped @@ -43,7 +38,6 @@ class ViewfinderController @Inject constructor( private val resolveAvailableModes: ResolveAvailableModes, private val resolveDroppedVideoQuality: ResolveDroppedVideoQuality, private val revertToMediaStoreLocation: RevertToMediaStoreLocation, - @MainImmediateDispatcher private val mainDispatcher: CoroutineDispatcher, ) : CameraSession.Listener { private var attachment: Attachment? = null @@ -78,13 +72,14 @@ class ViewfinderController @Inject constructor( return attached.session } - private var settings: CameraSettings = runBlocking { - settingsRepository.settings.first() - } + private val settings: CameraSettings + get() { + return settingsRepository.settings.value + } private var modeSettings: ModeSettings = ModeSettings() - private val preferencesScope = CoroutineScope(mainDispatcher) + private var slot: ModeSlot? = null var currentMode: CameraMode = DEFAULT_CAMERA_MODE private set @@ -163,9 +158,7 @@ class ViewfinderController @Inject constructor( } } set(value) { - settings = runBlocking { - settingsRepository.update { it.copy(aspectRatio = value) } - } + runBlocking { settingsRepository.update { it.copy(aspectRatio = value) } } } var scanAllCodes: Boolean by setting( @@ -192,9 +185,7 @@ class ViewfinderController @Inject constructor( var videoQuality: Quality get() = modeSettings.videoQuality set(value) { - runBlocking { - settingsRepository.setVideoQuality(value) - }?.let { modeSettings = it } + writeMode { slot -> settingsRepository.setVideoQuality(slot, value) } } // Session state rather than the stored value: geo-tagging is only ever on once the permission @@ -206,9 +197,7 @@ class ViewfinderController @Inject constructor( // 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 } + writeMode { slot -> settingsRepository.setGeoTagging(slot, value) } chrome.onGeoTaggingChanged(value) @@ -221,19 +210,11 @@ class ViewfinderController @Inject constructor( session.lensFacing == CameraSelector.LENS_FACING_FRONT } set(value) { - runBlocking { - settingsRepository.setSelfIllumination(value) - }?.let { modeSettings = it } + writeMode { slot -> settingsRepository.setSelfIllumination(slot, value) } chrome.onSelfIlluminationChanged(value) } - init { - preferencesScope.launch { - settingsRepository.settings.collect { settings = it } - } - } - fun attach( environment: CameraSessionEnvironment, effects: ViewfinderEffects, @@ -257,8 +238,6 @@ class ViewfinderController @Inject constructor( attachment = null mPlayer = null - - preferencesScope.cancel() } override fun onZoomStateChanged() { @@ -278,9 +257,7 @@ class ViewfinderController @Inject constructor( } fun setFlashMode(value: Int) { - runBlocking { - settingsRepository.setFlashMode(value) - }?.let { modeSettings = it } + writeMode { slot -> settingsRepository.setFlashMode(slot, value) } applyFlashMode(value) } @@ -531,12 +508,20 @@ class ViewfinderController @Inject constructor( } private fun slotCurrentMode() { - modeSettings = runBlocking { - settingsRepository.selectMode( - mode = currentMode, - isFrontFacing = session.lensFacing == CameraSelector.LENS_FACING_FRONT, - ) - } + 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 { @@ -602,7 +587,7 @@ class ViewfinderController @Inject constructor( } override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - settings = runBlocking { settingsRepository.update { write(it, value) } } + runBlocking { settingsRepository.update { write(it, value) } } onChanged(value) } 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/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 + } +} From 95955800012dc32ad34fc7a706a3bda7ba5e6ec6 Mon Sep 17 00:00:00 2001 From: Matvei Plokhov Date: Thu, 10 Sep 2026 00:05:55 +0200 Subject: [PATCH 23/23] Put camera session behind interface --- app/config/ktlint/baseline.xml | 11 +- app/detekt-baseline-debug.xml | 20 +- .../camera/ModeSwitchLatencyRegressionTest.kt | 27 ++- .../di/camera/SnapshotProbeCacheEntryPoint.kt | 12 ++ .../mapper/VideoQualityFeatureMapper.kt | 2 +- .../camera/model/CameraBindRequest.kt | 2 +- .../camera/model/CameraSessionPlan.kt | 2 +- .../camera/model/FeatureGroupRequest.kt | 2 +- .../camera/model/ImageCaptureMode.kt | 2 +- .../camera/model/InVideoSnapshotSupport.kt | 2 +- .../data/camera/model/SnapshotProbeKey.kt | 15 ++ .../data/camera/session/CameraSession.kt | 179 +++++++++--------- .../camera/session/CameraSessionFactory.kt | 16 ++ .../session/CameraSessionPlanFactory.kt} | 19 +- .../InVideoSnapshotSupportResolver.kt} | 14 +- .../data/camera/session/SnapshotProbeCache.kt | 65 +++++++ .../camera/di/camera/CameraBindsModule.kt | 40 ++-- .../usecase/ResolveDroppedVideoQuality.kt | 2 +- .../camera/ui/activities/MainActivity.kt | 3 +- .../VideoQualityFeatureMapperImplTest.kt | 2 +- .../CameraSessionPlanFactoryImplTest.kt} | 14 +- ...InVideoSnapshotSupportResolverImplTest.kt} | 20 +- .../ResolveDroppedVideoQualityImplTest.kt | 2 +- 23 files changed, 299 insertions(+), 174 deletions(-) create mode 100644 app/src/debug/java/app/grapheneos/camera/di/camera/SnapshotProbeCacheEntryPoint.kt rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/mapper/VideoQualityFeatureMapper.kt (95%) rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/model/CameraBindRequest.kt (91%) rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/model/CameraSessionPlan.kt (89%) rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/model/FeatureGroupRequest.kt (83%) rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/model/ImageCaptureMode.kt (66%) rename app/src/main/java/app/grapheneos/camera/{domain => data}/camera/model/InVideoSnapshotSupport.kt (90%) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/model/SnapshotProbeKey.kt create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionFactory.kt rename app/src/main/java/app/grapheneos/camera/{domain/camera/usecase/BuildCameraSessionPlan.kt => data/camera/session/CameraSessionPlanFactory.kt} (93%) rename app/src/main/java/app/grapheneos/camera/{domain/camera/usecase/ResolveInVideoSnapshotSupport.kt => data/camera/session/InVideoSnapshotSupportResolver.kt} (87%) create mode 100644 app/src/main/java/app/grapheneos/camera/data/camera/session/SnapshotProbeCache.kt rename app/src/test/java/app/grapheneos/camera/{domain => data}/camera/mapper/VideoQualityFeatureMapperImplTest.kt (97%) rename app/src/test/java/app/grapheneos/camera/{domain/camera/usecase/BuildCameraSessionPlanImplTest.kt => data/camera/session/CameraSessionPlanFactoryImplTest.kt} (93%) rename app/src/test/java/app/grapheneos/camera/{domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt => data/camera/session/InVideoSnapshotSupportResolverImplTest.kt} (79%) diff --git a/app/config/ktlint/baseline.xml b/app/config/ktlint/baseline.xml index 743d0717d..c87aba8a4 100644 --- a/app/config/ktlint/baseline.xml +++ b/app/config/ktlint/baseline.xml @@ -69,9 +69,6 @@ - - - @@ -526,10 +523,10 @@ - - - - + + + + diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml index 1a428c89c..30fbea964 100644 --- a/app/detekt-baseline-debug.xml +++ b/app/detekt-baseline-debug.xml @@ -4,7 +4,7 @@ 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:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome + 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 @@ -28,12 +28,12 @@ HasPlatformType:MainActivity.kt:MainActivity$val thumbnailLoaderExecutor = Executors.newSingleThreadExecutor() ImplicitDefaultLocale:QRAnalyzer.kt:QRAnalyzer$"%.02f".format(fps) ImplicitDefaultLocale:ZoomBar.kt:ZoomBar$String.format("%.1fx", zoomRatio) - InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is IllegalArgumentException - InstanceOfCheckForException:CameraSession.kt:CameraSession$exception !is UnsupportedOperationException - InstanceOfCheckForException:CameraSession.kt:CameraSession$exception is IllegalArgumentException + 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:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome + 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 @@ -134,10 +134,10 @@ ReturnCount:BarcodeFormats.kt:BarcodeFormats$fun setEnabled: Boolean ReturnCount:BottomTabLayout.kt:BottomTabLayout$private fun settleDuration: Long ReturnCount:ViewfinderController.kt:ViewfinderController$fun startCamera - ReturnCount:CameraSession.kt:CameraSession$@SuppressLint("RestrictedApi") fun bind: BindOutcome - ReturnCount:CameraSession.kt:CameraSession$fun isLensFacingSupported: Boolean - ReturnCount:CameraSession.kt:CameraSession$fun probeUnknownExtensions - ReturnCount:CameraSession.kt:CameraSession$private fun isExtensionUsable: Boolean + 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 @@ -159,7 +159,7 @@ SwallowedException:VideoCapturer.kt:VideoCapturer$e: Exception SwallowedException:VideoCapturer.kt:VideoCapturer$exception: Exception ThrowsCount:ImageSaver.kt:ImageSaver$@Throws(ImageSaverException::class) private fun saveImageInner - TooGenericExceptionCaught:CameraSession.kt:CameraSession$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 diff --git a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt index 02a2a74a5..c6073f870 100644 --- a/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt +++ b/app/src/androidTest/java/app/grapheneos/camera/ModeSwitchLatencyRegressionTest.kt @@ -3,10 +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.CameraSession +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 @@ -26,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 @@ -38,14 +50,14 @@ class ModeSwitchLatencyRegressionTest { var snapshotsFirstTime = false scenario.onActivity { - CameraSession.clearSnapshotProbeCache() + snapshotProbeCache.clear() it.viewfinder.switchMode(CameraMode.VIDEO) assertEquals( "entering video mode did not probe", 1, - CameraSession.snapshotProbeCount, + snapshotProbeCache.probeCount, ) snapshotsFirstTime = it.session.imageCapture != null } @@ -54,7 +66,7 @@ class ModeSwitchLatencyRegressionTest { it.viewfinder.switchMode(CameraMode.CAMERA) it.viewfinder.switchMode(CameraMode.VIDEO) - assertEquals("the second entry probed again", 1, CameraSession.snapshotProbeCount) + assertEquals("the second entry probed again", 1, snapshotProbeCache.probeCount) assertEquals( "the cached verdict answered for a different snapshot decision", snapshotsFirstTime, @@ -77,7 +89,7 @@ class ModeSwitchLatencyRegressionTest { awaitModeTabs(scenario) scenario.onActivity { - CameraSession.clearSnapshotProbeCache() + snapshotProbeCache.clear() it.viewfinder.switchMode(CameraMode.VIDEO) } @@ -90,7 +102,8 @@ 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.viewfinder.videoQuality @@ -107,7 +120,7 @@ class ModeSwitchLatencyRegressionTest { assertEquals( "the new quality was answered from the old verdict", 2, - CameraSession.snapshotProbeCount + snapshotProbeCache.probeCount ) } finally { selectVideoQuality(activity, original) 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/domain/camera/mapper/VideoQualityFeatureMapper.kt b/app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt similarity index 95% rename from app/src/main/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapper.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt index 245915954..2c9026698 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapper.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapper.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.mapper +package app.grapheneos.camera.data.camera.mapper import androidx.camera.core.featuregroup.GroupableFeature import androidx.camera.video.GroupableFeatures diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt similarity index 91% rename from app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt index 9db453541..eaab4779a 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraBindRequest.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraBindRequest.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.model +package app.grapheneos.camera.data.camera.model import androidx.camera.video.Quality diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt similarity index 89% rename from app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt index 74bc745b5..e4bf6c1cf 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/model/CameraSessionPlan.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/CameraSessionPlan.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.model +package app.grapheneos.camera.data.camera.model import androidx.camera.core.ImageCapture import androidx.camera.core.Preview diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt similarity index 83% rename from app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt index e2426e726..5d544edcd 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/model/FeatureGroupRequest.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/FeatureGroupRequest.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.model +package app.grapheneos.camera.data.camera.model import androidx.camera.core.featuregroup.GroupableFeature diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt similarity index 66% rename from app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt index 321a5fcd4..b0fbc409a 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/model/ImageCaptureMode.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/ImageCaptureMode.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.model +package app.grapheneos.camera.data.camera.model enum class ImageCaptureMode { MAXIMIZE_QUALITY, diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt similarity index 90% rename from app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt index 7f966a06a..3d876f16c 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/model/InVideoSnapshotSupport.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/model/InVideoSnapshotSupport.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.model +package app.grapheneos.camera.data.camera.model sealed interface InVideoSnapshotSupport { 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/session/CameraSession.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSession.kt index 131997ef1..ceff1ca1c 100644 --- 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 @@ -5,7 +5,6 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.util.Size -import androidx.annotation.VisibleForTesting import androidx.camera.core.Camera import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector @@ -29,48 +28,89 @@ 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 app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper -import app.grapheneos.camera.domain.camera.model.CameraBindRequest -import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest -import app.grapheneos.camera.domain.camera.model.ImageCaptureMode -import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport -import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan -import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport 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") -class CameraSession @AssistedInject constructor( +internal class CameraSessionImpl @AssistedInject constructor( @Assisted private val environment: CameraSessionEnvironment, private val cameraProviderSource: CameraProviderSource, private val extensionAvailabilityRepository: ExtensionAvailabilityRepository, private val featureCombinationSupport: FeatureCombinationSupport, - private val buildCameraSessionPlan: BuildCameraSessionPlan, + private val cameraSessionPlanFactory: CameraSessionPlanFactory, private val videoQualityFeatureMapper: VideoQualityFeatureMapper, - private val resolveInVideoSnapshotSupport: ResolveInVideoSnapshotSupport, -) { + private val inVideoSnapshotSupportResolver: InVideoSnapshotSupportResolver, + private val snapshotProbeCache: SnapshotProbeCache, +) : CameraSession { - var listener: Listener? = null + override var listener: CameraSession.Listener? = null - var camera: Camera? = null + override var camera: Camera? = null - var imageCapture: ImageCapture? = null + override var imageCapture: ImageCapture? = null - var preview: Preview? = null + override var preview: Preview? = null - var videoCapture: VideoCapture? = null + override var videoCapture: VideoCapture? = null - var iAnalyzer: ImageAnalysis? = null + override var iAnalyzer: ImageAnalysis? = null - var lensFacing = DEFAULT_LENS_FACING + override var lensFacing = DEFAULT_LENS_FACING private var cameraSelector: CameraSelector = CameraSelector.Builder() .requireLensFacing(DEFAULT_LENS_FACING) @@ -81,7 +121,7 @@ class CameraSession @AssistedInject constructor( // 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 + override var zoomState: ZoomState? = null private set private var zoomStateSource: LiveData? = null @@ -105,7 +145,7 @@ class CameraSession @AssistedInject constructor( zoomState = zoomStateSource?.value } - var cameraProvider: ProcessCameraProvider? = null + override var cameraProvider: ProcessCameraProvider? = null private var extensionsManager: ExtensionsManager? = null @@ -117,22 +157,22 @@ class CameraSession @AssistedInject constructor( Executors.newSingleThreadExecutor() } - val extensionsAvailable: Boolean + override val extensionsAvailable: Boolean get() { return extensionsManager != null && cameraProvider != null } - val isFlashAvailable: Boolean + override val isFlashAvailable: Boolean get() { return camera?.cameraInfo?.hasFlashUnit() ?: false } - val isZslSupported: Boolean + override val isZslSupported: Boolean get() { return camera?.cameraInfo?.isZslSupported ?: false } - var isTorchOn: Boolean = false + override var isTorchOn: Boolean = false get() { return camera?.cameraInfo?.torchState?.value == TorchState.ON } @@ -147,11 +187,11 @@ class CameraSession @AssistedInject constructor( } } - fun refreshQrHints() { + override fun refreshQrHints() { qrAnalyzer?.refreshHints() } - fun selectLensFacing(lensFacing: Int) { + override fun selectLensFacing(lensFacing: Int) { cameraSelector = CameraSelector.Builder() .requireLensFacing(lensFacing) .build() @@ -160,7 +200,7 @@ class CameraSession @AssistedInject constructor( // 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. - fun reattachZoomState() { + override fun reattachZoomState() { zoomStateSource?.removeObserver(zoomStateObserver) zoomStateSource = null zoomState = null @@ -176,7 +216,7 @@ class CameraSession @AssistedInject constructor( // 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 { + override fun canApplyVideoStabilization(): Boolean { return canVerifyFeatureCombinations() && isVideoStabilizationSupported() } @@ -199,7 +239,7 @@ class CameraSession @AssistedInject constructor( return Recorder.getVideoCapabilities(getCurrentCameraInfo()).isStabilizationSupported } - fun toggleTorchState() { + override fun toggleTorchState() { isTorchOn = !isTorchOn } @@ -211,7 +251,7 @@ class CameraSession @AssistedInject constructor( return provider.getCameraInfo(cameraSelector) } - fun initialize(forced: Boolean, extensionMode: Int) { + override fun initialize(forced: Boolean, extensionMode: Int) { if (cameraProvider != null) { listener?.onProviderReady(forced) return @@ -230,13 +270,12 @@ class CameraSession @AssistedInject constructor( forced: Boolean, extensionMode: Int, ) { - if (provider !== probedCameraProvider) { + 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() - snapshotSupport.clear() - probedCameraProvider = provider + snapshotProbeCache.adopt(provider) } cameraProvider = provider @@ -302,7 +341,7 @@ class CameraSession @AssistedInject constructor( // 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. - fun probeUnknownExtensions(onRestart: () -> Unit, onSettled: () -> Unit) { + override fun probeUnknownExtensions(onRestart: () -> Unit, onSettled: () -> Unit) { val pending = unprobedExtensions() if (pending.isEmpty()) { onSettled() @@ -329,7 +368,7 @@ class CameraSession @AssistedInject constructor( extensionProbesInFlight = false if (!environment.isSessionActive) return@execute - if (probedCameraProvider !== provider) { + 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 @@ -474,7 +513,7 @@ class CameraSession @AssistedInject constructor( // 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. - fun isLensFacingSupported(lensFacing: Int, extensionMode: Int): Boolean { + override fun isLensFacingSupported(lensFacing: Int, extensionMode: Int): Boolean { var tCameraSelector = CameraSelector.Builder() .requireLensFacing(lensFacing) .build() @@ -500,7 +539,7 @@ class CameraSession @AssistedInject constructor( } @SuppressLint("RestrictedApi") - fun bind(settings: CameraBindSettings): BindOutcome { + override fun bind(settings: CameraBindSettings): BindOutcome { val provider = requireNotNull(cameraProvider) { "Camera provider is not ready yet" } @@ -576,7 +615,7 @@ class CameraSession @AssistedInject constructor( featureGroup = featureGroup, ) - val plan = buildCameraSessionPlan(bindRequest) + val plan = cameraSessionPlanFactory.create(bindRequest) if (settings.isQrMode) { val analyzer = environment.createQrAnalyzer() @@ -628,7 +667,7 @@ class CameraSession @AssistedInject constructor( // 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 (snapshotSupport) and every entry into video mode + // 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) { @@ -640,8 +679,8 @@ class CameraSession @AssistedInject constructor( selectHighestResolution = settings.selectHighestResolution, ) - if (!snapshotSupport.containsKey(probeKey)) { - snapshotProbeCount++ + if (snapshotProbeCache[probeKey] == null) { + snapshotProbeCache.recordProbe() val cameraInfo = try { provider.getCameraInfo(cameraSelector) @@ -650,7 +689,7 @@ class CameraSession @AssistedInject constructor( return BindOutcome.FAILED } - snapshotSupport[probeKey] = resolveInVideoSnapshotSupport( + snapshotProbeCache[probeKey] = inVideoSnapshotSupportResolver.resolve( videoQualityFeature = requiredQualityFeature, probe = { withSnapshots, features -> val probedUseCases = when { @@ -668,7 +707,7 @@ class CameraSession @AssistedInject constructor( ) } - val support = snapshotSupport[probeKey] + val support = snapshotProbeCache[probeKey] if (support is InVideoSnapshotSupport.Unsupported) { Log.i(TAG, "${support.reason}; disabling snapshots while recording") useCasesList.remove(snapshotUseCase) @@ -754,41 +793,15 @@ class CameraSession @AssistedInject constructor( } } - interface Listener { - - fun onZoomStateChanged() - - fun onCameraProviderUnavailable() - - fun onExtensionsUnavailable() - - fun onProviderReady(forced: Boolean) - - fun onFeaturesSelected( - boundLensFacing: Int, - requested: List, - qualityFeature: GroupableFeature?, - selected: Set, - ) - } - @AssistedFactory interface Factory { - fun create(environment: CameraSessionEnvironment): CameraSession + fun create(environment: CameraSessionEnvironment): CameraSessionImpl } - private data class SnapshotProbeKey( - val lensFacing: Int, - val videoQuality: Quality, - val usesFeatureGroup: Boolean, - val captureMode: ImageCaptureMode, - val selectHighestResolution: Boolean, - ) - companion object { private const val TAG = "CameraSession" - const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK + private const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK private val FRONT_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_FRONT) @@ -797,27 +810,5 @@ class CameraSession @AssistedInject constructor( private val REAR_CAMERA_SELECTOR: CameraSelector = CameraSelector.Builder() .requireLensFacing(CameraSelector.LENS_FACING_BACK) .build() - - // 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 snapshotSupport = 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 probedCameraProvider: ProcessCameraProvider? = null - - // 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() { - snapshotSupport.clear() - snapshotProbeCount = 0 - } } } 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/domain/camera/usecase/BuildCameraSessionPlan.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt similarity index 93% rename from app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt index 936c8d871..b3b76e628 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlan.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactory.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.usecase +package app.grapheneos.camera.data.camera.session import android.annotation.SuppressLint import androidx.camera.core.ImageCapture @@ -12,21 +12,20 @@ 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.domain.camera.model.CameraBindRequest -import app.grapheneos.camera.domain.camera.model.CameraSessionPlan -import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest -import app.grapheneos.camera.domain.camera.model.ImageCaptureMode +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 BuildCameraSessionPlan { - - operator fun invoke(request: CameraBindRequest): CameraSessionPlan +interface CameraSessionPlanFactory { + fun create(request: CameraBindRequest): CameraSessionPlan } @SuppressLint("UnsafeOptInUsageError", "RestrictedApi") -internal class BuildCameraSessionPlanImpl @Inject constructor() : BuildCameraSessionPlan { +internal class CameraSessionPlanFactoryImpl @Inject constructor() : CameraSessionPlanFactory { - override fun invoke(request: CameraBindRequest): CameraSessionPlan { + override fun create(request: CameraBindRequest): CameraSessionPlan { val aspectRatioStrategy = AspectRatioStrategy( request.aspectRatio, AspectRatioStrategy.FALLBACK_RULE_AUTO, diff --git a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt b/app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt similarity index 87% rename from app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt rename to app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt index 41c183361..0d04fb57c 100644 --- a/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupport.kt +++ b/app/src/main/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolver.kt @@ -1,25 +1,25 @@ -package app.grapheneos.camera.domain.camera.usecase +package app.grapheneos.camera.data.camera.session import androidx.camera.core.featuregroup.GroupableFeature -import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +import app.grapheneos.camera.data.camera.model.InVideoSnapshotSupport import javax.inject.Inject fun interface SessionProbe { fun isSupported(withSnapshots: Boolean, features: Set): Boolean } -interface ResolveInVideoSnapshotSupport { +interface InVideoSnapshotSupportResolver { - operator fun invoke( + fun resolve( videoQualityFeature: GroupableFeature?, probe: SessionProbe, ): InVideoSnapshotSupport } -internal class ResolveInVideoSnapshotSupportImpl @Inject constructor() : - ResolveInVideoSnapshotSupport { +internal class InVideoSnapshotSupportResolverImpl @Inject constructor() : + InVideoSnapshotSupportResolver { - override fun invoke( + override fun resolve( videoQualityFeature: GroupableFeature?, probe: SessionProbe, ): InVideoSnapshotSupport { 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/di/camera/CameraBindsModule.kt b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt index 004150f09..8c8a9a381 100644 --- a/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt +++ b/app/src/main/java/app/grapheneos/camera/di/camera/CameraBindsModule.kt @@ -1,19 +1,23 @@ 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.domain.camera.mapper.VideoQualityFeatureMapper -import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapperImpl -import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlan -import app.grapheneos.camera.domain.camera.usecase.BuildCameraSessionPlanImpl +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 app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupport -import app.grapheneos.camera.domain.camera.usecase.ResolveInVideoSnapshotSupportImpl import dagger.Binds import dagger.Module import dagger.Reusable @@ -51,9 +55,9 @@ internal abstract class CameraBindsModule { @Binds @Reusable - abstract fun bindBuildCameraSessionPlan( - impl: BuildCameraSessionPlanImpl, - ): BuildCameraSessionPlan + abstract fun bindCameraSessionPlanFactory( + impl: CameraSessionPlanFactoryImpl, + ): CameraSessionPlanFactory @Binds @Reusable @@ -63,7 +67,19 @@ internal abstract class CameraBindsModule { @Binds @Reusable - abstract fun bindResolveInVideoSnapshotSupport( - impl: ResolveInVideoSnapshotSupportImpl, - ): ResolveInVideoSnapshotSupport + 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/domain/camera/usecase/ResolveDroppedVideoQuality.kt b/app/src/main/java/app/grapheneos/camera/domain/camera/usecase/ResolveDroppedVideoQuality.kt index 035389eea..b9b2edc9f 100644 --- 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 @@ -2,7 +2,7 @@ package app.grapheneos.camera.domain.camera.usecase import androidx.camera.core.featuregroup.GroupableFeature import androidx.camera.video.Quality -import app.grapheneos.camera.domain.camera.mapper.VideoQualityFeatureMapper +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 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 6fec795b9..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 @@ -79,6 +79,7 @@ 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.settings.repository.SettingsRepository import app.grapheneos.camera.databinding.ActivityMainBinding @@ -129,7 +130,7 @@ import kotlinx.coroutines.launch open class MainActivity : AppCompatActivity() { @Inject - lateinit var cameraSessionFactory: CameraSession.Factory + lateinit var cameraSessionFactory: CameraSessionFactory @Inject lateinit var viewfinder: ViewfinderController diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt similarity index 97% rename from app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt rename to app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt index 6672e7a6e..940f09ecc 100644 --- a/app/src/test/java/app/grapheneos/camera/domain/camera/mapper/VideoQualityFeatureMapperImplTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/camera/mapper/VideoQualityFeatureMapperImplTest.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.mapper +package app.grapheneos.camera.data.camera.mapper import androidx.camera.core.featuregroup.GroupableFeature import androidx.camera.video.GroupableFeatures diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt similarity index 93% rename from app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt rename to app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt index 55286db4c..0445f7aec 100644 --- a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/BuildCameraSessionPlanImplTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/camera/session/CameraSessionPlanFactoryImplTest.kt @@ -1,4 +1,4 @@ -package app.grapheneos.camera.domain.camera.usecase +package app.grapheneos.camera.data.camera.session import androidx.camera.core.AspectRatio import androidx.camera.core.ImageCapture @@ -7,10 +7,10 @@ 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.domain.camera.model.CameraBindRequest -import app.grapheneos.camera.domain.camera.model.CameraSessionPlan -import app.grapheneos.camera.domain.camera.model.FeatureGroupRequest -import app.grapheneos.camera.domain.camera.model.ImageCaptureMode +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 @@ -20,7 +20,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class BuildCameraSessionPlanImplTest { +class CameraSessionPlanFactoryImplTest { @Test fun invoke_photoMode_buildsAPhotoAndPreviewSession() { @@ -129,7 +129,7 @@ class BuildCameraSessionPlanImplTest { } private fun buildPlan(request: CameraBindRequest): CameraSessionPlan { - return BuildCameraSessionPlanImpl().invoke(request) + return CameraSessionPlanFactoryImpl().create(request) } private fun CameraSessionPlan.useCases(): List { diff --git a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt b/app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt similarity index 79% rename from app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt rename to app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt index 86f018347..3da097ff4 100644 --- a/app/src/test/java/app/grapheneos/camera/domain/camera/usecase/ResolveInVideoSnapshotSupportImplTest.kt +++ b/app/src/test/java/app/grapheneos/camera/data/camera/session/InVideoSnapshotSupportResolverImplTest.kt @@ -1,23 +1,23 @@ -package app.grapheneos.camera.domain.camera.usecase +package app.grapheneos.camera.data.camera.session import androidx.camera.core.featuregroup.GroupableFeature import androidx.camera.video.GroupableFeatures -import app.grapheneos.camera.domain.camera.model.InVideoSnapshotSupport +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 ResolveInVideoSnapshotSupportImplTest { +class InVideoSnapshotSupportResolverImplTest { - private val resolve = ResolveInVideoSnapshotSupportImpl() + private val resolver = InVideoSnapshotSupportResolverImpl() @Test fun invoke_noQualityAskedForAndTheStreamsBind_keepsSnapshots() { val probe = FakeSessionProbe(supported = setOf(PLAIN_STREAMS_WITH_SNAPSHOTS)) - val support = resolve(videoQualityFeature = null, probe = probe) + val support = resolver.resolve(videoQualityFeature = null, probe = probe) assertEquals(InVideoSnapshotSupport.Supported, support) } @@ -26,7 +26,7 @@ class ResolveInVideoSnapshotSupportImplTest { fun invoke_noQualityAskedForAndTheStreamsDoNotBind_dropsSnapshots() { val probe = FakeSessionProbe(supported = emptySet()) - val support = resolve(videoQualityFeature = null, probe = probe) + val support = resolver.resolve(videoQualityFeature = null, probe = probe) assertEquals(InVideoSnapshotSupport.Unsupported.STREAM_COMBINATION, support) } @@ -35,7 +35,7 @@ class ResolveInVideoSnapshotSupportImplTest { fun invoke_theQualityBindsWithSnapshots_keepsSnapshotsWithoutAskingAgain() { val probe = FakeSessionProbe(supported = setOf(QUALITY_WITH_SNAPSHOTS)) - val support = resolve(videoQualityFeature = QUALITY, probe = probe) + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) assertEquals(InVideoSnapshotSupport.Supported, support) assertEquals(listOf(QUALITY_WITH_SNAPSHOTS), probe.asked) @@ -45,7 +45,7 @@ class ResolveInVideoSnapshotSupportImplTest { fun invoke_theQualityBindsOnlyWithoutSnapshots_givesUpTheSnapshots() { val probe = FakeSessionProbe(supported = setOf(QUALITY_WITHOUT_SNAPSHOTS)) - val support = resolve(videoQualityFeature = QUALITY, probe = probe) + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) assertEquals(InVideoSnapshotSupport.Unsupported.SELECTED_VIDEO_QUALITY, support) } @@ -54,7 +54,7 @@ class ResolveInVideoSnapshotSupportImplTest { fun invoke_theQualityBindsNeitherWay_keepsSnapshotsAndLeavesTheQualityToTheResolver() { val probe = FakeSessionProbe(supported = setOf(PLAIN_STREAMS_WITH_SNAPSHOTS)) - val support = resolve(videoQualityFeature = QUALITY, probe = probe) + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) assertEquals(InVideoSnapshotSupport.Supported, support) } @@ -63,7 +63,7 @@ class ResolveInVideoSnapshotSupportImplTest { fun invoke_nothingBinds_dropsSnapshots() { val probe = FakeSessionProbe(supported = emptySet()) - val support = resolve(videoQualityFeature = QUALITY, probe = probe) + val support = resolver.resolve(videoQualityFeature = QUALITY, probe = probe) assertEquals(InVideoSnapshotSupport.Unsupported.STREAM_COMBINATION, support) } 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 index 48b1af88a..0b5480d41 100644 --- 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 @@ -4,7 +4,7 @@ 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.domain.camera.mapper.VideoQualityFeatureMapperImpl +import app.grapheneos.camera.data.camera.mapper.VideoQualityFeatureMapperImpl import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test