diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt index ccb4f081d..3dc4dc43f 100644 --- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt +++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt @@ -1476,7 +1476,13 @@ class CamConfig(private val mActivity: MainActivity) { if (isQRMode) { mActivity.qrOverlay.visibility = View.VISIBLE - mActivity.thirdOption.visibility = View.INVISIBLE + + mActivity.thirdOption.visibility = View.VISIBLE + mActivity.whiteOptionCircle.visibility = View.INVISIBLE + mActivity.imagePreview.visibility = View.INVISIBLE + mActivity.thirdCircle.visibility = View.INVISIBLE + mActivity.playPreview.visibility = View.INVISIBLE + mActivity.qrImageImport.visibility = View.VISIBLE if (scanAllCodes) { mActivity.flipCamIcon.setImageResource( @@ -1499,6 +1505,12 @@ class CamConfig(private val mActivity: MainActivity) { } else { mActivity.qrOverlay.visibility = View.INVISIBLE mActivity.thirdOption.visibility = View.VISIBLE + + mActivity.qrImageImport.visibility = View.GONE + mActivity.whiteOptionCircle.visibility = View.VISIBLE + mActivity.imagePreview.visibility = View.VISIBLE + mActivity.thirdCircle.visibility = View.VISIBLE + mActivity.flipCamIcon.setImageResource(R.drawable.flip_camera) mActivity.cancelButtonView.visibility = View.VISIBLE 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..dacd63a60 100644 --- a/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt +++ b/app/src/main/java/app/grapheneos/camera/analyzer/QRAnalyzer.kt @@ -1,5 +1,6 @@ package app.grapheneos.camera.analyzer +import android.graphics.Bitmap import android.util.Log import androidx.camera.core.ImageAnalysis.Analyzer import androidx.camera.core.ImageProxy @@ -9,6 +10,7 @@ import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.MultiFormatReader import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.RGBLuminanceSource import com.google.zxing.ReaderException import com.google.zxing.common.HybridBinarizer import java.util.EnumMap @@ -17,6 +19,32 @@ import kotlin.math.roundToInt class QRAnalyzer(private val mActivity: MainActivity) : Analyzer { companion object { private const val TAG = "QRCodeImageAnalyzer" + + fun scanStillImage(bitmap: Bitmap, possibleFormats: List): String? { + val width = bitmap.width + val height = bitmap.height + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + val hints: MutableMap = EnumMap(DecodeHintType::class.java) + hints[DecodeHintType.POSSIBLE_FORMATS] = possibleFormats + hints[DecodeHintType.TRY_HARDER] = true + + val reader = MultiFormatReader().apply { setHints(hints) } + + val source = RGBLuminanceSource(width, height, pixels) + decodeOrNull(reader, BinaryBitmap(HybridBinarizer(source)))?.let { return it } + return decodeOrNull(reader, BinaryBitmap(HybridBinarizer(source.invert()))) + } + + private fun decodeOrNull(reader: MultiFormatReader, binaryBitmap: BinaryBitmap): String? { + reader.reset() + return try { + reader.decodeWithState(binaryBitmap).text + } catch (e: ReaderException) { + null + } + } } private var frameCounter = 0 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 6895accce..e4c6fe213 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 @@ -45,6 +45,7 @@ import android.widget.ProgressBar import android.widget.RelativeLayout import android.widget.TextView import androidx.activity.enableEdgeToEdge +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions import androidx.annotation.StringRes @@ -70,6 +71,7 @@ import app.grapheneos.camera.CameraMode import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.ITEM_TYPE_VIDEO import app.grapheneos.camera.R +import app.grapheneos.camera.analyzer.QRAnalyzer import app.grapheneos.camera.capturer.ImageCapturer import app.grapheneos.camera.capturer.VideoCapturer import app.grapheneos.camera.capturer.getVideoThumbnail @@ -156,6 +158,9 @@ open class MainActivity : AppCompatActivity(), lateinit var thirdOption: View lateinit var imagePreview: ShapeableImageView lateinit var previewLoader: ProgressBar + lateinit var whiteOptionCircle: ImageView + lateinit var playPreview: ImageView + lateinit var qrImageImport: ImageButton private var isZooming = false lateinit var zoomBar: ZoomBar @@ -315,6 +320,42 @@ open class MainActivity : AppCompatActivity(), Log.i(TAG, "Selected location: ${data?.encodedPath!!}") } + private val qrImagePickerLauncher = registerForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri: Uri? -> + if (uri != null) { + scanQRFromImage(uri) + } + } + + private fun scanQRFromImage(uri: Uri) { + val bitmap = try { + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } catch (e: Exception) { + Log.w(TAG, "Unable to decode picked image for QR scan", e) + showMessage(R.string.could_not_read_image) + return + } + + val possibleFormats = if (camConfig.scanAllCodes) { + BarcodeFormat.values().asList() + } else { + camConfig.allowedFormats + } + + val result = QRAnalyzer.scanStillImage(bitmap, possibleFormats) + bitmap.recycle() + + if (result != null) { + onScanResultSuccess(result) + } else { + showMessage(R.string.no_code_found_in_image) + } + } + private fun showAudioPermissionDeniedDialog(onDisableAudio: () -> Unit = {}) { val builder = MaterialAlertDialogBuilder(this) .setTitle(R.string.audio_permission_dialog_title) @@ -606,6 +647,15 @@ open class MainActivity : AppCompatActivity(), thirdOption = binding.thirdOption previewLoader = binding.previewLoading imagePreview = binding.imagePreview + whiteOptionCircle = binding.whiteOptionCircle + playPreview = binding.playPreview + qrImageImport = binding.qrImageImport + qrImageImport.setOnClickListener { + resetAutoSleep() + qrImagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } previewView = binding.preview previewView.scaleType = PreviewView.ScaleType.FIT_START previewContainer = binding.previewContainer diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt index da57086c2..e62ca9ca1 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoCaptureActivity.kt @@ -5,22 +5,15 @@ import android.net.Uri import android.os.Bundle import android.provider.MediaStore.EXTRA_OUTPUT import android.view.View -import android.widget.ImageView import app.grapheneos.camera.R class VideoCaptureActivity : CaptureActivity() { - private lateinit var whiteOptionCircle: ImageView - private lateinit var playPreview: ImageView - private var savedUri: Uri? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - whiteOptionCircle = findViewById(R.id.white_option_circle) - playPreview = findViewById(R.id.play_preview) - captureButton.setImageResource(R.drawable.recording) captureButton.setOnClickListener OnClickListener@{ diff --git a/app/src/main/res/drawable/scan_from_image.xml b/app/src/main/res/drawable/scan_from_image.xml new file mode 100644 index 000000000..268dbb90c --- /dev/null +++ b/app/src/main/res/drawable/scan_from_image.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bb02b81cb..cc167f93b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -365,6 +365,18 @@ android:layout_gravity="center" android:contentDescription="@string/play_preview"/> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46b266ed7..630a44a1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,6 +61,9 @@ Start recording without audio QR Scan toggle QR Code + Scan code from an image + No code found in the selected image. + Could not read the selected image. Data Matrix Aztec Code More Options