From dbe81ffbecb42984ecec128b00982553c34b61c1 Mon Sep 17 00:00:00 2001 From: Stefan Kolloch Date: Thu, 30 Jul 2026 16:48:32 +0200 Subject: [PATCH] Add optional maximum photo resolution cap Add a "Max resolution" setting (More settings) that caps the long edge of captured photos to a configurable number of pixels. 0 (the default) keeps the current behaviour of using the largest available resolution for the chosen aspect ratio. The cap is applied via a CameraX ResolutionStrategy (FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER) alongside the existing aspect ratio strategy, so it works independently of the aspect ratio and across devices. Useful for keeping documentation-style photos small. The cap and the existing "Use highest photo resolution" option are mutually exclusive: enabling one disables and clears the other in the UI, and the cap takes precedence if both are somehow set. Co-Authored-By: Claude Opus 4.8 --- .../java/app/grapheneos/camera/CamConfig.kt | 41 ++++++++- .../camera/ui/activities/MoreSettings.kt | 52 +++++++++++- app/src/main/res/layout/more_settings.xml | 85 +++++++++++++++++++ app/src/main/res/values/strings.xml | 6 ++ 4 files changed, 181 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index 9f11ea197..84b11d297 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -129,6 +129,8 @@ class CamConfig(private val mActivity: MainActivity) { const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" + const val MAX_IMAGE_LONG_EDGE = "max_image_long_edge" + const val SELF_TIMER_DURATION = "self_timer_duration" } @@ -175,6 +177,9 @@ class CamConfig(private val mActivity: MainActivity) { const val WAIT_FOR_FOCUS_LOCK = false + // 0 = no cap (use the largest resolution for the chosen aspect ratio) + const val MAX_IMAGE_LONG_EDGE = 0 + const val SELF_TIMER_DURATION = 0 } } @@ -574,6 +579,21 @@ class CamConfig(private val mActivity: MainActivity) { editor.apply() } + // Cap for the long edge of captured photos, in pixels. 0 = no cap. Keeps documentation + // photos small independently of the aspect ratio / sensor. + var maxImageLongEdge: Int + get() { + return commonPref.getInt( + SettingValues.Key.MAX_IMAGE_LONG_EDGE, + SettingValues.Default.MAX_IMAGE_LONG_EDGE + ) + } + set(value) { + val editor = commonPref.edit() + editor.putInt(SettingValues.Key.MAX_IMAGE_LONG_EDGE, value) + editor.apply() + } + var removeExifAfterCapture: Boolean get() { return commonPref.getBoolean( @@ -1578,10 +1598,29 @@ class CamConfig(private val mActivity: MainActivity) { val resolutionSelectorBuilder = ResolutionSelector.Builder() .setAspectRatioStrategy(aspectRatioStrategy) - if (selectHighestResolution) { + // The cap and "highest resolution" are mutually exclusive in the UI; if both + // are somehow set, the cap below takes precedence. + if (selectHighestResolution && maxImageLongEdge == 0) { resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) } + // Cap the capture resolution when configured (0 = no cap). Picks the largest + // supported size whose long edge is <= the cap for the current aspect ratio. + val longEdgeCap = maxImageLongEdge + if (longEdgeCap > 0) { + val target = if (aspectRatio == AspectRatio.RATIO_16_9) { + Size(longEdgeCap, longEdgeCap * 9 / 16) + } else { + Size(longEdgeCap, longEdgeCap * 3 / 4) + } + resolutionSelectorBuilder.setResolutionStrategy( + ResolutionStrategy( + target, + ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER + ) + ) + } + it.setResolutionSelector(resolutionSelectorBuilder.build()) it.setFlashMode(flashMode) 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 ef5c03b77..12d8710c5 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 @@ -41,6 +41,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { private lateinit var rootView: View private lateinit var pQField: EditText + private lateinit var mleField: EditText private val dirPickerHandler = registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -149,6 +150,10 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { pQField.filters = arrayOf(NumInputFilter(this)) pQField.setOnEditorActionListener(this) + mleField = binding.maxLongEdge + mleField.setText(camConfig.maxImageLongEdge.toString()) + mleField.setOnEditorActionListener(this) + val exifToggle = binding.removeExifToggle val exifToggleSetting = binding.removeExifSetting @@ -241,11 +246,21 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { highResToggle.isChecked = camConfig.selectHighestResolution highResToggle.setOnClickListener { - camConfig.selectHighestResolution = !camConfig.selectHighestResolution + camConfig.selectHighestResolution = highResToggle.isChecked + // "Highest resolution" and the resolution cap contradict each other; keep them + // mutually exclusive by clearing the cap when the highest-res mode is enabled. + if (highResToggle.isChecked && camConfig.maxImageLongEdge > 0) { + camConfig.maxImageLongEdge = 0 + mleField.setText("0") + } + syncResolutionControls() } highResSetting.setOnClickListener { - highResToggle.performClick() + // Ignore taps while a resolution cap keeps this row disabled. + if (camConfig.maxImageLongEdge == 0) { + highResToggle.performClick() + } } if (!showStorageSettings) { @@ -268,6 +283,8 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { v.setPadding(cutouts.left, 0, cutouts.right, 0) insets } + + syncResolutionControls() } override fun dispatchTouchEvent(event: MotionEvent): Boolean { @@ -327,6 +344,37 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener { } else { camConfig.photoQuality = quality } + + // Dump state of max image resolution (long edge). 0 = no cap; empty/invalid -> revert. + val longEdge = mleField.text.toString().toIntOrNull() + if (longEdge == null || longEdge < 0) { + mleField.setText(camConfig.maxImageLongEdge.toString()) + if (notifyOnInvalidValue) { + showMessage(getString(R.string.invalid_max_long_edge_value)) + } + } else { + camConfig.maxImageLongEdge = longEdge + // Mutually exclusive with "highest resolution": a cap takes precedence. + if (longEdge > 0 && camConfig.selectHighestResolution) { + camConfig.selectHighestResolution = false + binding.highestResSettingToggle.isChecked = false + } + } + + syncResolutionControls() + } + + // "Highest resolution" and the resolution cap are mutually exclusive; reflect that by + // disabling whichever control the other one currently overrides. + private fun syncResolutionControls() { + val capActive = camConfig.maxImageLongEdge > 0 + val highestActive = camConfig.selectHighestResolution + + mleField.isEnabled = !highestActive + binding.maxLongEdgeSetting.alpha = if (highestActive) 0.5f else 1f + + binding.highestResSettingToggle.isEnabled = !capActive + binding.highestResSetting.alpha = if (capActive) 0.5f else 1f } override fun onEditorAction(p0: TextView?, id: Int, p2: KeyEvent?): Boolean { diff --git a/app/src/main/res/layout/more_settings.xml b/app/src/main/res/layout/more_settings.xml index 4ec526cbb..1c04e845b 100644 --- a/app/src/main/res/layout/more_settings.xml +++ b/app/src/main/res/layout/more_settings.xml @@ -388,6 +388,91 @@ + + + + + + + + + + + + + + + + + + + + + + + Wait for Focus Lock Invalid value set for photo quality + + + Max resolution + Longest edge in pixels for captured photos. 0 = no limit. If the value is below the smallest size the camera supports, that smallest size is used. + px + Invalid value set for maximum resolution