diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index d558133f7..085f8352f 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -65,6 +65,10 @@ import com.google.zxing.BarcodeFormat import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import kotlin.concurrent.thread +import androidx.camera.camera2.interop.Camera2CameraControl +import androidx.camera.camera2.interop.CaptureRequestOptions +import android.hardware.camera2.CaptureRequest +import android.util.Range // note that enum constant name is used as a name of a SharedPreferences instance enum class CameraMode(val extensionMode: Int, val uiName: Int) { @@ -76,6 +80,7 @@ enum class CameraMode(val extensionMode: Int, val uiName: Int) { HDR(ExtensionMode.HDR, R.string.hdr_mode), CAMERA(ExtensionMode.NONE, R.string.camera), VIDEO(ExtensionMode.NONE, R.string.video), + MANUAL(ExtensionMode.NONE, R.string.manual_mode) } @SuppressLint("UnsafeOptInUsageError") @@ -274,6 +279,9 @@ class CamConfig(private val mActivity: MainActivity) { var lastCapturedItem: CapturedItem? = null + var manualIsoValue: Int? = null + var manualExposureTimeValue: Long? = null + init { if (mActivity !is SecureActivity) { CapturedItems.init(mActivity, this) @@ -313,6 +321,9 @@ class CamConfig(private val mActivity: MainActivity) { var isQRMode = false private set + var isManualMode = false + private set + val isFlashAvailable: Boolean get() = camera?.cameraInfo?.hasFlashUnit() ?: false @@ -1016,6 +1027,46 @@ class CamConfig(private val mActivity: MainActivity) { } } + @androidx.annotation.OptIn(androidx.camera.camera2.interop.ExperimentalCamera2Interop::class) + fun getIsoRange(): Range? { + val cameraInfo = camera?.cameraInfo ?: return null + return Camera2CameraInfo.from(cameraInfo) + .getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) + } + + @androidx.annotation.OptIn(androidx.camera.camera2.interop.ExperimentalCamera2Interop::class) + fun getShutterRange(): Range? { + val cameraInfo = camera?.cameraInfo ?: return null + return Camera2CameraInfo.from(cameraInfo) + .getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE) + } + + /** + * Applies manual ISO and Exposure Time settings to the camera hardware. + * If camera is not in manual mode, it restores Auto Exposure (AE) mode. + */ + @androidx.annotation.OptIn(androidx.camera.camera2.interop.ExperimentalCamera2Interop::class) + fun applyManualSettings() { + val cameraControl = camera?.cameraControl ?: return + val camera2CameraControl = Camera2CameraControl.from(cameraControl) + val builder = CaptureRequestOptions.Builder() + + if (!mActivity.camConfig.isManualMode) { + builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) + } else { + builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF) + manualIsoValue?.let { + builder.setCaptureRequestOption(CaptureRequest.SENSOR_SENSITIVITY, it) + } + manualExposureTimeValue?.let { + builder.setCaptureRequestOption(CaptureRequest.SENSOR_EXPOSURE_TIME, it) + } + } + + camera2CameraControl.setCaptureRequestOptions(builder.build()) + } + + fun toggleAspectRatio() { aspectRatio = if (aspectRatio == AspectRatio.RATIO_16_9) { AspectRatio.RATIO_4_3 @@ -1786,6 +1837,8 @@ class CamConfig(private val mActivity: MainActivity) { camera?.cameraInfo?.exposureState?.let { mActivity.exposureBar.setExposureConfig(it) } + applyManualSettings() + mActivity.settingsDialog.torchToggle.isChecked = false // Focus camera on touch/tap @@ -1860,7 +1913,7 @@ class CamConfig(private val mActivity: MainActivity) { private fun availableModes(): Set { return CameraMode.entries.filter { when (it) { - CameraMode.CAMERA, CameraMode.VIDEO -> true + CameraMode.CAMERA, CameraMode.VIDEO, CameraMode.MANUAL -> true CameraMode.QR_SCAN -> mActivity !is SecureMainActivity else -> { check(it.extensionMode != ExtensionMode.NONE) @@ -2013,6 +2066,8 @@ class CamConfig(private val mActivity: MainActivity) { isVideoMode = mode == CameraMode.VIDEO + isManualMode = mode == CameraMode.MANUAL + if (isQRMode) { mActivity.qrOverlay.visibility = View.VISIBLE mActivity.thirdOption.visibility = View.INVISIBLE @@ -2036,6 +2091,13 @@ class CamConfig(private val mActivity: MainActivity) { mActivity.setCaptureButtonIcon(R.drawable.torch_off_button, R.string.turn_torch_on) mActivity.micOffIcon.visibility = View.GONE + mActivity.isoButton.visibility = View.GONE + mActivity.isoButton.isSelected = false + mActivity.isoSliderContainer.visibility = View.GONE + //mActivity.updateIsoButtonUI() + manualIsoValue = null + manualExposureTimeValue = null + applyManualSettings() } else { mActivity.qrOverlay.visibility = View.INVISIBLE mActivity.thirdOption.visibility = View.VISIBLE @@ -2052,6 +2114,21 @@ class CamConfig(private val mActivity: MainActivity) { mActivity.setCaptureButtonIcon(R.drawable.camera_shutter, R.string.capture) mActivity.micOffIcon.visibility = View.GONE } + + mActivity.isoButton.visibility = if (isManualMode) View.VISIBLE else View.GONE + mActivity.exposureTimeButton.visibility = if (isManualMode) View.VISIBLE else View.GONE + if (!isManualMode) { + mActivity.deselectAllManualControls() + manualIsoValue = null + manualExposureTimeValue = null + applyManualSettings() + mActivity.startFocusTimer() + }else{ + manualIsoValue = mActivity.isoSeekBar.getCurrentIsoValue() + manualExposureTimeValue = mActivity.exposureTimeBar.getCurrentExposureTimeValue() + applyManualSettings() + mActivity.cancelFocusTimer() + } } mActivity.updateSelfTimerBadge() 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 5412822c2..910451eb8 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 @@ -11,6 +11,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Bitmap +import android.graphics.Color import android.graphics.ImageDecoder import android.graphics.Point import android.graphics.Rect @@ -87,6 +88,8 @@ import app.grapheneos.camera.ui.QROverlay import app.grapheneos.camera.ui.QRToggle import app.grapheneos.camera.ui.SettingsDialog import app.grapheneos.camera.ui.seekbar.ExposureBar +import app.grapheneos.camera.ui.seekbar.IsoBar +import app.grapheneos.camera.ui.seekbar.ExposureTimeBar import app.grapheneos.camera.ui.seekbar.ZoomBar import app.grapheneos.camera.ui.showIgnoringShortEdgeMode import app.grapheneos.camera.util.CameraControl @@ -229,8 +232,15 @@ open class MainActivity : AppCompatActivity(), previewView.height / 2.0f, QROverlay.RATIO ) + // In manual mode, we only want to focus (AF), not change exposure (AE) + val flags = if (camConfig.isManualMode) { + FocusMeteringAction.FLAG_AF + } else { + FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE or FocusMeteringAction.FLAG_AWB + } + camConfig.camera?.cameraControl?.startFocusAndMetering( - FocusMeteringAction.Builder(autoFocusPoint).disableAutoCancel().build() + FocusMeteringAction.Builder(autoFocusPoint, flags).disableAutoCancel().build() ) startFocusTimer() @@ -248,6 +258,41 @@ open class MainActivity : AppCompatActivity(), } lateinit var micOffIcon: ImageView + lateinit var isoButton: TextView + + lateinit var isoSeekBar: IsoBar + lateinit var isoSliderContainer: View + lateinit var isoValueText: TextView + + lateinit var exposureTimeButton: TextView + lateinit var exposureTimeBar: ExposureTimeBar + lateinit var exposureTimeSliderContainer: View + lateinit var exposureTimeValueText: TextView + + private val manualControlPairs by lazy { + listOf( + isoButton to isoSliderContainer, + exposureTimeButton to exposureTimeSliderContainer + ) + } + + + fun updateManualButtonsUI(){ + val buttons : List = listOf(isoButton, exposureTimeButton) + for(b in buttons){ + updateManualButtonUI(b) + } + } + + private fun updateManualButtonUI(button: TextView) { + if (button.isSelected) { + button.setBackgroundResource(R.drawable.manual_button_pressed_bg) + button.setTextColor(Color.BLACK) + } else { + button.setBackgroundResource(R.drawable.manual_button_bg) + button.setTextColor(Color.WHITE) + } + } private var shouldRestartRecording = false @@ -617,6 +662,23 @@ open class MainActivity : AppCompatActivity(), camConfig = CamConfig(this) cameraControl = CameraControl(camConfig) mainOverlay = binding.mainOverlay + isoButton = binding.isoButton + + isoSliderContainer = binding.isoSliderContainer + isoValueText = binding.isoValueText + exposureTimeButton = binding.exposureTimeButton + exposureTimeValueText = binding.exposureTimeValueText + + isoSeekBar = binding.isoSeekbar + exposureTimeBar = binding.exposureTimeSeekbar + + isoSeekBar.setMainActivity(this) + exposureTimeBar.setMainActivity(this) + + exposureTimeSliderContainer = binding.exposureTimeSliderContainer + + + imageCapturer = ImageCapturer(this) videoCapturer = VideoCapturer(this) thirdOption = binding.thirdOption @@ -645,6 +707,8 @@ open class MainActivity : AppCompatActivity(), if (state == StreamState.STREAMING) { mainOverlay.visibility = View.INVISIBLE camConfig.reloadSettings() + isoSeekBar.refreshIsoValues() + exposureTimeBar.refreshExposureTimeValues() if (!camConfig.isQRMode) { previewGrid.visibility = View.VISIBLE if (!settingsDialog.isShowing) { @@ -779,6 +843,38 @@ open class MainActivity : AppCompatActivity(), } } + isoButton.setOnClickListener { + val targetState = !it.isSelected + deselectAllManualControls() + it.isSelected = targetState + + if (it.isSelected) { + isoSliderContainer.visibility = View.VISIBLE + if (camConfig.manualIsoValue == null) { + camConfig.manualIsoValue = isoSeekBar.getCurrentIsoValue() + } + } else { + isoSliderContainer.visibility = View.GONE + } + camConfig.applyManualSettings() + updateManualButtonsUI() + } + + exposureTimeButton.setOnClickListener { + val targetState = !it.isSelected + deselectAllManualControls() + it.isSelected = targetState + + if (it.isSelected) { + exposureTimeSliderContainer.visibility = View.VISIBLE + camConfig.manualExposureTimeValue = exposureTimeBar.getCurrentExposureTimeValue() + } else { + exposureTimeSliderContainer.visibility = View.GONE + } + camConfig.applyManualSettings() + updateManualButtonsUI() + } + cancelButtonView = binding.cancelButton zoomBar = binding.zoomBar @@ -1233,13 +1329,23 @@ open class MainActivity : AppCompatActivity(), if (camConfig.isQRMode) return false + if(camConfig.isManualMode) + 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) + // In manual mode, we only want to focus (AF), not change exposure (AE) + val flags = if (camConfig.isManualMode) { + FocusMeteringAction.FLAG_AF + } else { + FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE or FocusMeteringAction.FLAG_AWB + } + + val focusBuilder = FocusMeteringAction.Builder(autoFocusPoint, flags) if (!camConfig.isVideoMode) { camConfig.mPlayer.playFocusStartSound() @@ -1805,6 +1911,15 @@ open class MainActivity : AppCompatActivity(), application.resetPreventScreenFromSleeping() } + fun deselectAllManualControls() { + manualControlPairs.forEach { (button, container) -> + button.isSelected = false + container.visibility = View.GONE + } + updateManualButtonsUI() + } + + @Volatile var isStarted = false override fun onStart() { diff --git a/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureTimeBar.kt b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureTimeBar.kt new file mode 100644 index 000000000..dac7e4e6b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/seekbar/ExposureTimeBar.kt @@ -0,0 +1,88 @@ +package app.grapheneos.camera.ui.seekbar + +import android.content.Context +import android.util.AttributeSet +import android.widget.SeekBar +import androidx.annotation.OptIn +import androidx.appcompat.widget.AppCompatSeekBar +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.activities.MainActivity + +class ExposureTimeBar : AppCompatSeekBar { + + // Values in seconds (Double) to generate real ns + private val exposureTimes = listOf( + 1/6000.0, 1/4000.0, 1/3000.0, 1/2000.0, 1/1000.0, 1/750.0, 1/500.0, + 1/350.0, 1/250.0, 1/125.0, 1/60.0, 1/30.0, 1/25.0, 1/15.0, 1/10.0, + 1/8.0, 1/6.0, 1/4.0, 0.3, 0.5, 1.0, 2.0, 4.0, 8.0, 10.0, 15.0, 30.0 + ) + + + private var exposureTimeValues: List = emptyList() + private lateinit var mainActivity: MainActivity + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) + + fun setMainActivity(mainActivity: MainActivity) { + this.mainActivity = mainActivity + + this.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (progress < exposureTimeValues.size) { + val selectedNanos = exposureTimeValues[progress] + + val label = formatExposureValuesSpeed(exposureTimes[progress]) + mainActivity.exposureTimeValueText.text = label + mainActivity.exposureTimeValueText.text = label + + if(mainActivity.camConfig.isManualMode){ + mainActivity.camConfig.manualExposureTimeValue = selectedNanos + mainActivity.camConfig.applyManualSettings() + } + } + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + + @OptIn(ExperimentalCamera2Interop::class) + fun refreshExposureTimeValues() { + val range = mainActivity.camConfig.getShutterRange() + val minNanos = range?.lower ?: 100_000L // 1/10000s default + val maxNanos = range?.upper ?: 1_000_000_000L // 1s default + + val validPairs = exposureTimes.map { (it * 1_000_000_000.0).toLong() to it } + .filter { it.first in minNanos..maxNanos } + + exposureTimeValues = validPairs.map { it.first } + val filteredSpeeds = validPairs.map { it.second } + + this.max = exposureTimeValues.size - 1 + + if (this.progress >= exposureTimeValues.size) { + this.progress = 0 + } + + val initialLabel = formatExposureValuesSpeed(filteredSpeeds[this.progress]) + mainActivity.exposureTimeValueText.text = initialLabel + } + + private fun formatExposureValuesSpeed(seconds: Double): String { + return when { + seconds >= 1.0 -> context.getString(R.string.exposure_time_seconds, seconds.toInt()) + seconds == 0.3 || seconds == 0.5 -> context.getString(R.string.exposure_time_decimal, seconds) + else -> { + val denominator = (1.0 / seconds + 0.5).toInt() + context.getString(R.string.exposure_time_fraction, denominator) + } + } + } + + fun getCurrentExposureTimeValue(): Long { + return if (progress < exposureTimeValues.size) exposureTimeValues[progress] else exposureTimeValues.last() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/seekbar/IsoBar.kt b/app/src/main/java/app/grapheneos/camera/ui/seekbar/IsoBar.kt new file mode 100644 index 000000000..f1fd2a91f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/seekbar/IsoBar.kt @@ -0,0 +1,73 @@ +package app.grapheneos.camera.ui.seekbar + +import android.content.Context +import android.util.AttributeSet +import android.widget.SeekBar +import androidx.appcompat.widget.AppCompatSeekBar +import app.grapheneos.camera.ui.activities.MainActivity + +class IsoBar : AppCompatSeekBar { + + + private var isoValues: List = listOf(50, 100, 200, 400, 800, 1600) + private lateinit var mainActivity: MainActivity + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) + + + fun setMainActivity(mainActivity: MainActivity) { + this.mainActivity = mainActivity + + val range = mainActivity.camConfig.getIsoRange() + + if (range != null) { + val minIso = range.lower + val maxIso = range.upper + isoValues = listOf(minIso, 100, 200, 400, 800, 1600, maxIso).distinct().sorted() + } + this.max = isoValues.size - 1 + + this.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if(progress < isoValues.size){ + val selectedIso = isoValues[progress] + mainActivity.isoValueText.text = selectedIso.toString(); + + mainActivity.camConfig.manualIsoValue = selectedIso + mainActivity.camConfig.applyManualSettings() + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + + fun refreshIsoValues() { + val range = mainActivity.camConfig.getIsoRange() + if (range != null) { + val minIso = range.lower + val maxIso = range.upper + isoValues = listOf(minIso, 100, 200, 400, 800, 1600, maxIso).distinct().sorted() + this.max = isoValues.size - 1 + + if (this.progress >= isoValues.size) { + this.progress = 0 + } + val selectedIso = isoValues[this.progress] + mainActivity.isoValueText.text = selectedIso.toString() + + if (mainActivity.camConfig.isManualMode) { + mainActivity.camConfig.manualIsoValue = selectedIso + mainActivity.camConfig.applyManualSettings() + } + } + } + + fun getCurrentIsoValue(): Int { + return if (progress < isoValues.size) isoValues[progress] else isoValues.last() + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/manual_button_bg.xml b/app/src/main/res/drawable/manual_button_bg.xml new file mode 100644 index 000000000..35fa7a713 --- /dev/null +++ b/app/src/main/res/drawable/manual_button_bg.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/manual_button_pressed_bg.xml b/app/src/main/res/drawable/manual_button_pressed_bg.xml new file mode 100644 index 000000000..fe3ed9878 --- /dev/null +++ b/app/src/main/res/drawable/manual_button_pressed_bg.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b2aa0db88..6913c804e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -215,6 +215,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + FACE RETOUCH AUTO QR SCAN + MANUAL 16:9 4:3 Share Media @@ -86,6 +87,13 @@ More Options PDF 417 More Settings + ISO + 100 + Exposure Time + 100 + 1/%1$d + %1$ds + %1$.1f General