diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index ccb4f081..71abb7a3 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -3,6 +3,8 @@ package app.grapheneos.camera import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences +import android.graphics.ImageFormat +import android.hardware.camera2.CameraCharacteristics import android.net.Uri import android.os.Build import android.provider.MediaStore @@ -32,6 +34,7 @@ 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.camera2.interop.Camera2CameraInfo import androidx.camera.extensions.ExtensionMode import androidx.camera.extensions.ExtensionsManager import androidx.camera.lifecycle.ProcessCameraProvider @@ -56,6 +59,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.zxing.BarcodeFormat import java.util.concurrent.ExecutionException import java.util.concurrent.Executors +import kotlin.math.log // note that enum constant name is used as a name of a SharedPreferences instance enum class CameraMode(val extensionMode: Int, val uiName: Int) { @@ -119,6 +123,8 @@ class CamConfig(private val mActivity: MainActivity) { const val WAIT_FOR_FOCUS_LOCK = "wait_for_focus_lock" + const val CAPTURE_RESOLUTION = "capture_resolution" + // const val IMAGE_FILE_FORMAT = "image_quality" // const val VIDEO_FILE_FORMAT = "video_quality" } @@ -179,6 +185,14 @@ class CamConfig(private val mActivity: MainActivity) { const val DEFAULT_LENS_FACING = CameraSelector.LENS_FACING_BACK + fun aspectRatioToWidthHeight(aspectRatio: Int): Pair { + return when (aspectRatio) { + AspectRatio.RATIO_16_9 -> Pair(16, 9) + AspectRatio.RATIO_4_3 -> Pair(4, 3) + else -> throw IllegalArgumentException("Unknown aspect ratio: $aspectRatio") + } + } + val commonFormats = arrayOf( BarcodeFormat.AZTEC, BarcodeFormat.QR_CODE, @@ -722,6 +736,8 @@ class CamConfig(private val mActivity: MainActivity) { if (isVideoMode) { mActivity.settingsDialog.reloadQualities() + } else { + mActivity.settingsDialog.reloadResolutions() } if (lensFacing == CameraSelector.LENS_FACING_FRONT) { @@ -923,6 +939,27 @@ class CamConfig(private val mActivity: MainActivity) { } } + var captureResolution: Size? + get() { + val value = commonPref.getString(SettingValues.Key.CAPTURE_RESOLUTION, null) + if (value.isNullOrEmpty()) return null + return try { + val parts = value.split("x") + Size(parts[0].toInt(), parts[1].toInt()) + } catch (e: Exception) { + null + } + } + set(value) { + commonPref.edit { + if (value == null) { + remove(SettingValues.Key.CAPTURE_RESOLUTION) + } else { + putString(SettingValues.Key.CAPTURE_RESOLUTION, "${value.width}x${value.height}") + } + } + } + var selectHighestResolution: Boolean get() { return commonPref.getBoolean( @@ -989,6 +1026,8 @@ class CamConfig(private val mActivity: MainActivity) { } else { AspectRatio.RATIO_16_9 } + // Clear capture resolution since available resolutions depend on aspect ratio + captureResolution = null startCamera(true) } @@ -996,6 +1035,28 @@ class CamConfig(private val mActivity: MainActivity) { return cameraProvider!!.getCameraInfo(cameraSelector) } + fun getAvailableImageResolutions(): List { + val cameraInfo = camera?.cameraInfo ?: return emptyList() + val camera2Info = Camera2CameraInfo.from(cameraInfo) + val characteristics = camera2Info.getCameraCharacteristic( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP + ) ?: return emptyList() + + val sizes = characteristics.getOutputSizes(ImageFormat.JPEG) ?: return emptyList() + + // Filter by current aspect ratio with 2% tolerance + val targetRatio = when (aspectRatio) { + AspectRatio.RATIO_16_9 -> 16.0 / 9.0 + AspectRatio.RATIO_4_3 -> 4.0 / 3.0 + else -> 4.0 / 3.0 + } + + return sizes.filter { size -> + val ratio = size.width.toDouble() / size.height.toDouble() + kotlin.math.abs(ratio - targetRatio) / targetRatio < 0.02 + }.sortedByDescending { it.width * it.height } + } + fun toggleCameraSelector() { // Manually switch to the opposite lens facing @@ -1229,6 +1290,12 @@ class CamConfig(private val mActivity: MainActivity) { resolutionSelectorBuilder.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) } + captureResolution?.let { size -> + resolutionSelectorBuilder.setResolutionStrategy( + ResolutionStrategy(size, ResolutionStrategy.FALLBACK_RULE_NONE) + ) + } + it.setResolutionSelector(resolutionSelectorBuilder.build()) it.setFlashMode(flashMode) 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 6477716c..12adfad5 100644 --- a/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt +++ b/app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt @@ -3,6 +3,7 @@ package app.grapheneos.camera.capturer import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context +import android.graphics.Bitmap import android.graphics.ImageDecoder import android.graphics.ImageFormat import android.graphics.Rect @@ -38,7 +39,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean // see com.android.externalstorage.ExternalStorageProvider and // com.android.internal.content.FileSystemProvider 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 067c81fa..475204b0 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/SettingsDialog.kt @@ -65,6 +65,10 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : private lateinit var vQAdapter: ArrayAdapter private var focusTimeoutSpinner: Spinner private var timerSpinner: Spinner + private var captureResolutionSpinner: Spinner + private lateinit var captureResolutionAdapter: ArrayAdapter + private var availableResolutions: List = emptyList() + private var aspectRatioForResolutions: Int? = null; var mScrollView: ScrollView var mScrollViewContent: View @@ -83,6 +87,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : private var selfIlluminationSetting: View private var videoQualitySetting: View private var timerSetting: View + private var captureResolutionSetting: View var settingsFrame: View @@ -321,6 +326,22 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : override fun onNothingSelected(p0: AdapterView<*>?) {} } + captureResolutionSpinner = binding.captureResolutionSpinner + captureResolutionSpinner.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + p0: AdapterView<*>?, + p1: View?, + position: Int, + p3: Long + ) { + camConfig.captureResolution = indexToResolution(position) + camConfig.startCamera(true) + } + + override fun onNothingSelected(p0: AdapterView<*>?) {} + } + mScrollView = binding.settingsScrollview mScrollViewContent = binding.settingsScrollviewContent @@ -329,6 +350,7 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : selfIlluminationSetting = binding.selfIlluminationSetting videoQualitySetting = binding.videoQualitySetting timerSetting = binding.timerSetting + captureResolutionSetting = binding.captureResolutionSetting includeAudioToggle = binding.includeAudioSwitch includeAudioToggle.setOnCheckedChangeListener { _, _ -> @@ -466,8 +488,55 @@ class SettingsDialog(val mActivity: MainActivity, themedContext: Context) : } else { View.VISIBLE } + + captureResolutionSetting.visibility = if (camConfig.isVideoMode) { + View.GONE + } else { + View.VISIBLE + } + } + + private fun resolutionToIndex(size: android.util.Size?): Int { + if (size == null) return 0 // Highest resolution + return availableResolutions.indexOfFirst { it.width == size.width && it.height == size.height } + } + + private fun indexToResolution(index: Int): android.util.Size? { + return if (index >= 0 && index < availableResolutions.size) { + availableResolutions[index] + } else { + null + } } + fun reloadResolutions() { + if (aspectRatioForResolutions !== null && aspectRatioForResolutions?.equals(camConfig.aspectRatio) == true) { + // Use cached data + return; + } + + availableResolutions = camConfig.getAvailableImageResolutions() + + val titles = mutableListOf() + availableResolutions.forEach { size -> + titles.add("${size.width}x${size.height}") + } + + captureResolutionAdapter = ArrayAdapter( + mActivity, + android.R.layout.simple_spinner_item, + titles + ) + + captureResolutionAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item + ) + + captureResolutionSpinner.adapter = captureResolutionAdapter + captureResolutionSpinner.setSelection(resolutionToIndex(camConfig.captureResolution)) + + aspectRatioForResolutions = camConfig.aspectRatio; + } fun updateFocusTimeout(selectedOption: String) { diff --git a/app/src/main/res/layout/settings.xml b/app/src/main/res/layout/settings.xml index 49909f0e..de2da910 100644 --- a/app/src/main/res/layout/settings.xml +++ b/app/src/main/res/layout/settings.xml @@ -382,6 +382,40 @@ + + + + + + + + + + + Self Illumination Focus Timeout Timer + Capture Resolution Cancel Timer Record Video