Skip to content

Commit dbe81ff

Browse files
Stefan Kollochclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 84aa73a commit dbe81ff

4 files changed

Lines changed: 181 additions & 3 deletions

File tree

app/src/main/java/app/grapheneos/camera/CamConfig.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ class CamConfig(private val mActivity: MainActivity) {
129129

130130
const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock"
131131

132+
const val MAX_IMAGE_LONG_EDGE = "max_image_long_edge"
133+
132134
const val SELF_TIMER_DURATION = "self_timer_duration"
133135
}
134136

@@ -175,6 +177,9 @@ class CamConfig(private val mActivity: MainActivity) {
175177

176178
const val WAIT_FOR_FOCUS_LOCK = false
177179

180+
// 0 = no cap (use the largest resolution for the chosen aspect ratio)
181+
const val MAX_IMAGE_LONG_EDGE = 0
182+
178183
const val SELF_TIMER_DURATION = 0
179184
}
180185
}
@@ -574,6 +579,21 @@ class CamConfig(private val mActivity: MainActivity) {
574579
editor.apply()
575580
}
576581

582+
// Cap for the long edge of captured photos, in pixels. 0 = no cap. Keeps documentation
583+
// photos small independently of the aspect ratio / sensor.
584+
var maxImageLongEdge: Int
585+
get() {
586+
return commonPref.getInt(
587+
SettingValues.Key.MAX_IMAGE_LONG_EDGE,
588+
SettingValues.Default.MAX_IMAGE_LONG_EDGE
589+
)
590+
}
591+
set(value) {
592+
val editor = commonPref.edit()
593+
editor.putInt(SettingValues.Key.MAX_IMAGE_LONG_EDGE, value)
594+
editor.apply()
595+
}
596+
577597
var removeExifAfterCapture: Boolean
578598
get() {
579599
return commonPref.getBoolean(
@@ -1578,10 +1598,29 @@ class CamConfig(private val mActivity: MainActivity) {
15781598
val resolutionSelectorBuilder = ResolutionSelector.Builder()
15791599
.setAspectRatioStrategy(aspectRatioStrategy)
15801600

1581-
if (selectHighestResolution) {
1601+
// The cap and "highest resolution" are mutually exclusive in the UI; if both
1602+
// are somehow set, the cap below takes precedence.
1603+
if (selectHighestResolution && maxImageLongEdge == 0) {
15821604
resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE)
15831605
}
15841606

1607+
// Cap the capture resolution when configured (0 = no cap). Picks the largest
1608+
// supported size whose long edge is <= the cap for the current aspect ratio.
1609+
val longEdgeCap = maxImageLongEdge
1610+
if (longEdgeCap > 0) {
1611+
val target = if (aspectRatio == AspectRatio.RATIO_16_9) {
1612+
Size(longEdgeCap, longEdgeCap * 9 / 16)
1613+
} else {
1614+
Size(longEdgeCap, longEdgeCap * 3 / 4)
1615+
}
1616+
resolutionSelectorBuilder.setResolutionStrategy(
1617+
ResolutionStrategy(
1618+
target,
1619+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER
1620+
)
1621+
)
1622+
}
1623+
15851624
it.setResolutionSelector(resolutionSelectorBuilder.build())
15861625

15871626
it.setFlashMode(flashMode)

app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
4141
private lateinit var rootView: View
4242

4343
private lateinit var pQField: EditText
44+
private lateinit var mleField: EditText
4445

4546
private val dirPickerHandler = registerForActivityResult(
4647
ActivityResultContracts.StartActivityForResult()
@@ -149,6 +150,10 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
149150
pQField.filters = arrayOf(NumInputFilter(this))
150151
pQField.setOnEditorActionListener(this)
151152

153+
mleField = binding.maxLongEdge
154+
mleField.setText(camConfig.maxImageLongEdge.toString())
155+
mleField.setOnEditorActionListener(this)
156+
152157
val exifToggle = binding.removeExifToggle
153158
val exifToggleSetting = binding.removeExifSetting
154159

@@ -241,11 +246,21 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
241246
highResToggle.isChecked = camConfig.selectHighestResolution
242247

243248
highResToggle.setOnClickListener {
244-
camConfig.selectHighestResolution = !camConfig.selectHighestResolution
249+
camConfig.selectHighestResolution = highResToggle.isChecked
250+
// "Highest resolution" and the resolution cap contradict each other; keep them
251+
// mutually exclusive by clearing the cap when the highest-res mode is enabled.
252+
if (highResToggle.isChecked && camConfig.maxImageLongEdge > 0) {
253+
camConfig.maxImageLongEdge = 0
254+
mleField.setText("0")
255+
}
256+
syncResolutionControls()
245257
}
246258

247259
highResSetting.setOnClickListener {
248-
highResToggle.performClick()
260+
// Ignore taps while a resolution cap keeps this row disabled.
261+
if (camConfig.maxImageLongEdge == 0) {
262+
highResToggle.performClick()
263+
}
249264
}
250265

251266
if (!showStorageSettings) {
@@ -268,6 +283,8 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
268283
v.setPadding(cutouts.left, 0, cutouts.right, 0)
269284
insets
270285
}
286+
287+
syncResolutionControls()
271288
}
272289

273290
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
@@ -327,6 +344,37 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
327344
} else {
328345
camConfig.photoQuality = quality
329346
}
347+
348+
// Dump state of max image resolution (long edge). 0 = no cap; empty/invalid -> revert.
349+
val longEdge = mleField.text.toString().toIntOrNull()
350+
if (longEdge == null || longEdge < 0) {
351+
mleField.setText(camConfig.maxImageLongEdge.toString())
352+
if (notifyOnInvalidValue) {
353+
showMessage(getString(R.string.invalid_max_long_edge_value))
354+
}
355+
} else {
356+
camConfig.maxImageLongEdge = longEdge
357+
// Mutually exclusive with "highest resolution": a cap takes precedence.
358+
if (longEdge > 0 && camConfig.selectHighestResolution) {
359+
camConfig.selectHighestResolution = false
360+
binding.highestResSettingToggle.isChecked = false
361+
}
362+
}
363+
364+
syncResolutionControls()
365+
}
366+
367+
// "Highest resolution" and the resolution cap are mutually exclusive; reflect that by
368+
// disabling whichever control the other one currently overrides.
369+
private fun syncResolutionControls() {
370+
val capActive = camConfig.maxImageLongEdge > 0
371+
val highestActive = camConfig.selectHighestResolution
372+
373+
mleField.isEnabled = !highestActive
374+
binding.maxLongEdgeSetting.alpha = if (highestActive) 0.5f else 1f
375+
376+
binding.highestResSettingToggle.isEnabled = !capActive
377+
binding.highestResSetting.alpha = if (capActive) 0.5f else 1f
330378
}
331379

332380
override fun onEditorAction(p0: TextView?, id: Int, p2: KeyEvent?): Boolean {

app/src/main/res/layout/more_settings.xml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,91 @@
388388
</LinearLayout>
389389

390390

391+
<LinearLayout
392+
android:id="@+id/max_long_edge_setting"
393+
android:layout_width="match_parent"
394+
android:layout_height="wrap_content"
395+
android:background="?android:attr/selectableItemBackground"
396+
android:clickable="true"
397+
android:focusable="true"
398+
android:orientation="horizontal"
399+
android:paddingTop="8dp"
400+
android:paddingHorizontal="16dp"
401+
android:paddingBottom="10dp">
402+
403+
<ImageView
404+
android:id="@+id/max_long_edge_icon"
405+
android:layout_width="48dp"
406+
android:layout_height="48dp"
407+
android:importantForAccessibility="no"
408+
android:paddingStart="4dp"
409+
android:paddingEnd="8dp"
410+
android:src="@drawable/megapixel" />
411+
412+
<LinearLayout
413+
android:layout_width="0dp"
414+
android:layout_height="wrap_content"
415+
android:layout_marginHorizontal="4dp"
416+
android:layout_weight="1"
417+
android:orientation="vertical">
418+
419+
<TextView
420+
android:id="@+id/max_long_edge_title"
421+
android:layout_width="wrap_content"
422+
android:layout_height="wrap_content"
423+
android:layout_marginStart="10dp"
424+
android:paddingBottom="2dp"
425+
android:text="@string/max_long_edge_title"
426+
android:textColor="?android:textColorPrimary"
427+
android:textSize="16sp" />
428+
429+
<TextView
430+
android:id="@+id/max_long_edge_subtitle"
431+
android:layout_width="wrap_content"
432+
android:layout_height="wrap_content"
433+
android:paddingStart="10dp"
434+
android:text="@string/max_long_edge_desc"
435+
android:textSize="14sp"
436+
tools:ignore="RtlSymmetry" />
437+
438+
</LinearLayout>
439+
440+
<LinearLayout
441+
android:layout_width="wrap_content"
442+
android:layout_height="match_parent"
443+
android:orientation="horizontal">
444+
445+
<EditText
446+
android:id="@+id/max_long_edge"
447+
android:layout_width="52dp"
448+
android:layout_height="wrap_content"
449+
android:layout_gravity="center"
450+
android:layout_marginStart="2dp"
451+
android:clickable="true"
452+
android:focusableInTouchMode="true"
453+
android:imeOptions="actionDone"
454+
android:importantForAutofill="no"
455+
android:inputType="number"
456+
android:labelFor="@id/max_long_edge_setting"
457+
android:maxLength="4"
458+
android:paddingTop="15dp"
459+
android:textAlignment="center"
460+
android:textSize="16sp" />
461+
462+
<TextView
463+
android:layout_width="wrap_content"
464+
android:layout_height="wrap_content"
465+
android:layout_gravity="center"
466+
android:layout_marginEnd="4dp"
467+
android:paddingTop="16dp"
468+
android:text="@string/pixel_symbol"
469+
android:textSize="16sp" />
470+
471+
</LinearLayout>
472+
473+
</LinearLayout>
474+
475+
391476
<LinearLayout
392477
android:id="@+id/highest_res_setting"
393478
android:layout_width="match_parent"

app/src/main/res/values/strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,10 @@
234234

235235
<string name="wait_for_focus_lock">Wait for Focus Lock</string>
236236
<string name="invalid_photo_quality_value">Invalid value set for photo quality</string>
237+
238+
<!-- Max resolution (long edge) setting -->
239+
<string name="max_long_edge_title">Max resolution</string>
240+
<string name="max_long_edge_desc">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.</string>
241+
<string name="pixel_symbol">px</string>
242+
<string name="invalid_max_long_edge_value">Invalid value set for maximum resolution</string>
237243
</resources>

0 commit comments

Comments
 (0)