diff --git a/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java b/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java index c04b236..74dac0e 100644 --- a/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java +++ b/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java @@ -2,8 +2,11 @@ package com.foxx.androidcast.qr; import android.Manifest; import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.graphics.RectF; import android.os.Bundle; import android.util.Size; +import android.view.MotionEvent; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; @@ -11,8 +14,13 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.Camera; import androidx.camera.core.CameraSelector; +import androidx.camera.core.FocusMeteringAction; import androidx.camera.core.ImageAnalysis; +import androidx.camera.core.ImageProxy; +import androidx.camera.core.MeteringPoint; +import androidx.camera.core.MeteringPointFactory; import androidx.camera.core.Preview; import androidx.camera.lifecycle.ProcessCameraProvider; import androidx.camera.view.PreviewView; @@ -28,15 +36,26 @@ import com.google.mlkit.vision.barcode.BarcodeScanning; import com.google.mlkit.vision.barcode.common.Barcode; import com.google.mlkit.vision.common.InputImage; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** Camera QR scanner — routes our auth URLs in-app. */ public final class QrScanActivity extends AppCompatActivity { + private static final int CLEAR_FRAME_AFTER_EMPTY_FRAMES = 8; + private final AtomicBoolean handled = new AtomicBoolean(false); + private int emptyFrameStreak; private ExecutorService cameraExecutor; private BarcodeScanner scanner; + @Nullable + private Camera camera; + @Nullable + private PreviewView previewView; + @Nullable + private QrScanFrameOverlay frameOverlay; private final ActivityResultLauncher cameraPermission = registerForActivityResult( new ActivityResultContracts.RequestPermission(), @@ -59,6 +78,16 @@ public final class QrScanActivity extends AppCompatActivity { getSupportActionBar().setTitle(R.string.qr_scan_title); } + previewView = findViewById(R.id.qr_preview); + frameOverlay = findViewById(R.id.qr_frame_overlay); + if (previewView != null) { + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + } + if (frameOverlay != null) { + frameOverlay.setClickable(true); + frameOverlay.setOnTouchListener(this::onOverlayTouch); + } + cameraExecutor = Executors.newSingleThreadExecutor(); BarcodeScannerOptions options = new BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) @@ -74,12 +103,15 @@ public final class QrScanActivity extends AppCompatActivity { } private void startCamera() { - PreviewView previewView = findViewById(R.id.qr_preview); + PreviewView preview = previewView; + if (preview == null) { + return; + } ListenableFuture future = ProcessCameraProvider.getInstance(this); future.addListener(() -> { try { ProcessCameraProvider provider = future.get(); - bindCamera(provider, previewView); + bindCamera(provider, preview); } catch (Exception e) { Toast.makeText(this, R.string.qr_scan_camera_error, Toast.LENGTH_LONG).show(); finish(); @@ -96,30 +128,100 @@ public final class QrScanActivity extends AppCompatActivity { .setTargetResolution(new Size(1280, 720)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build(); - analysis.setAnalyzer(cameraExecutor, imageProxy -> { + analysis.setAnalyzer(cameraExecutor, this::analyzeFrame); + + camera = provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis); + } + + private boolean onOverlayTouch(@NonNull android.view.View v, @NonNull MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_UP) { + focusAt(event.getX(), event.getY()); + } + return true; + } + + private void focusAt(float viewX, float viewY) { + Camera cam = camera; + PreviewView preview = previewView; + QrScanFrameOverlay overlay = frameOverlay; + if (cam == null || preview == null) { + return; + } + MeteringPointFactory factory = preview.getMeteringPointFactory(); + MeteringPoint point = factory.createPoint(viewX, viewY); + FocusMeteringAction action = new FocusMeteringAction.Builder( + point, FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE) + .setAutoCancelDuration(4, TimeUnit.SECONDS) + .build(); + cam.getCameraControl().startFocusAndMetering(action); + if (overlay != null) { + overlay.showFocusPulse(viewX, viewY); + } + } + + private void analyzeFrame(@NonNull ImageProxy imageProxy) { + if (handled.get()) { + imageProxy.close(); + return; + } + if (imageProxy.getImage() == null) { + imageProxy.close(); + return; + } + InputImage image = InputImage.fromMediaImage( + imageProxy.getImage(), imageProxy.getImageInfo().getRotationDegrees()); + scanner.process(image) + .addOnSuccessListener(barcodes -> runOnUiThread(() -> onBarcodesDetected(barcodes, imageProxy))) + .addOnFailureListener(e -> imageProxy.close()); + } + + private void onBarcodesDetected(@NonNull List barcodes, @NonNull ImageProxy imageProxy) { + try { if (handled.get()) { - imageProxy.close(); return; } - InputImage image = InputImage.fromMediaImage( - imageProxy.getImage(), imageProxy.getImageInfo().getRotationDegrees()); - scanner.process(image) - .addOnSuccessListener(barcodes -> { - for (Barcode barcode : barcodes) { - String raw = barcode.getRawValue(); - if (raw != null && !raw.isEmpty() && handled.compareAndSet(false, true)) { - runOnUiThread(() -> { - AcAppLinkRouter.open(QrScanActivity.this, raw); - finish(); - }); - break; - } - } - }) - .addOnCompleteListener(task -> imageProxy.close()); - }); + PreviewView preview = previewView; + QrScanFrameOverlay overlay = frameOverlay; + Barcode best = pickBestBarcode(barcodes); + if (best == null) { + emptyFrameStreak++; + if (emptyFrameStreak >= CLEAR_FRAME_AFTER_EMPTY_FRAMES && overlay != null) { + overlay.clearDetectedFrame(); + } + return; + } + emptyFrameStreak = 0; + Rect box = best.getBoundingBox(); + if (box != null && preview != null && overlay != null && preview.getWidth() > 0) { + RectF mapped = QrScanCoordinateMapper.mapBarcodeToPreview(box, imageProxy, preview); + overlay.setDetectedFrame(mapped); + } + String raw = best.getRawValue(); + if (raw != null && !raw.isEmpty() && handled.compareAndSet(false, true)) { + AcAppLinkRouter.open(QrScanActivity.this, raw); + finish(); + } + } finally { + imageProxy.close(); + } + } - provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis); + @Nullable + private static Barcode pickBestBarcode(@NonNull List barcodes) { + Barcode best = null; + int bestArea = 0; + for (Barcode barcode : barcodes) { + Rect box = barcode.getBoundingBox(); + if (box == null) { + continue; + } + int area = box.width() * box.height(); + if (area > bestArea) { + bestArea = area; + best = barcode; + } + } + return best; } @Override @@ -130,6 +232,7 @@ public final class QrScanActivity extends AppCompatActivity { if (cameraExecutor != null) { cameraExecutor.shutdown(); } + camera = null; super.onDestroy(); } diff --git a/app/src/main/java/com/foxx/androidcast/qr/QrScanCoordinateMapper.java b/app/src/main/java/com/foxx/androidcast/qr/QrScanCoordinateMapper.java new file mode 100644 index 0000000..521ce8b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/qr/QrScanCoordinateMapper.java @@ -0,0 +1,29 @@ +package com.foxx.androidcast.qr; + +import android.graphics.Rect; +import android.graphics.RectF; + +import androidx.annotation.NonNull; +import androidx.camera.core.ImageProxy; +import androidx.camera.view.PreviewView; +import androidx.camera.view.transform.CoordinateTransform; +import androidx.camera.view.transform.ImageProxyTransformFactory; +import androidx.camera.view.transform.OutputTransform; + +/** Maps ML Kit barcode bounds from analysis image space to {@link PreviewView} coordinates. */ +final class QrScanCoordinateMapper { + private QrScanCoordinateMapper() {} + + @NonNull + static RectF mapBarcodeToPreview( + @NonNull Rect barcodeBox, + @NonNull ImageProxy imageProxy, + @NonNull PreviewView previewView) { + ImageProxyTransformFactory factory = new ImageProxyTransformFactory(); + OutputTransform source = factory.getOutputTransform(imageProxy); + CoordinateTransform transform = new CoordinateTransform(source, previewView.getOutputTransform()); + RectF mapped = new RectF(barcodeBox); + transform.mapRect(mapped); + return mapped; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/qr/QrScanFrameOverlay.java b/app/src/main/java/com/foxx/androidcast/qr/QrScanFrameOverlay.java new file mode 100644 index 0000000..f253ef0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/qr/QrScanFrameOverlay.java @@ -0,0 +1,242 @@ +package com.foxx.androidcast.qr; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.foxx.androidcast.R; + +/** Scan target overlay: centered default frame, tracks detected QR bounds. */ +public final class QrScanFrameOverlay extends View { + private static final float DEFAULT_FRACTION = 0.62f; + private static final long ANIM_MS = 120L; + + private final Paint dimPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint bracketPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path dimPath = new Path(); + private final RectF drawFrame = new RectF(); + private final RectF animStart = new RectF(); + private final RectF animEnd = new RectF(); + private final RectF defaultFrame = new RectF(); + + private float cornerLengthPx; + private float strokePx; + private float minSidePx; + private float padPx; + + private boolean tracking; + @Nullable + private ValueAnimator frameAnimator; + @Nullable + private ValueAnimator focusAnimator; + + private final Paint focusPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private float focusX = -1f; + private float focusY = -1f; + private float focusRadius; + private float focusAlpha; + + public QrScanFrameOverlay(@NonNull Context context) { + this(context, null); + } + + public QrScanFrameOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + dimPaint.setStyle(Paint.Style.FILL); + dimPaint.setColor(ContextCompat.getColor(context, R.color.qr_scan_dim)); + bracketPaint.setStyle(Paint.Style.STROKE); + bracketPaint.setStrokeCap(Paint.Cap.SQUARE); + bracketPaint.setColor(ContextCompat.getColor(context, R.color.qr_scan_frame)); + float density = context.getResources().getDisplayMetrics().density; + cornerLengthPx = 28f * density; + strokePx = 3f * density; + bracketPaint.setStrokeWidth(strokePx); + minSidePx = 72f * density; + padPx = 10f * density; + focusPaint.setStyle(Paint.Style.STROKE); + focusPaint.setStrokeWidth(strokePx); + focusPaint.setColor(ContextCompat.getColor(context, R.color.qr_scan_frame)); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + computeDefaultFrame(w, h); + if (!tracking) { + drawFrame.set(defaultFrame); + } + } + + private void computeDefaultFrame(int w, int h) { + float side = Math.min(w, h) * DEFAULT_FRACTION; + float left = (w - side) * 0.5f; + float top = (h - side) * 0.5f; + defaultFrame.set(left, top, left + side, top + side); + } + + /** Brief ring at tap location while the camera refocuses. */ + public void showFocusPulse(float x, float y) { + focusX = x; + focusY = y; + if (focusAnimator != null) { + focusAnimator.cancel(); + } + float maxRadius = 36f * getResources().getDisplayMetrics().density; + focusAnimator = ValueAnimator.ofFloat(0f, 1f); + focusAnimator.setDuration(450L); + focusAnimator.addUpdateListener(a -> { + float t = (float) a.getAnimatedValue(); + focusRadius = maxRadius * (0.35f + 0.65f * t); + focusAlpha = 1f - t; + invalidate(); + }); + focusAnimator.start(); + } + + /** Move the target frame to {@code previewRect} (view coordinates). */ + public void setDetectedFrame(@NonNull RectF previewRect) { + RectF target = new RectF(previewRect); + target.inset(-padPx, -padPx); + enforceMinSize(target); + clampToView(target); + tracking = true; + bracketPaint.setColor(ContextCompat.getColor(getContext(), R.color.qr_scan_frame_detected)); + animateTo(target); + } + + /** Return to the centered default target when nothing is detected. */ + public void clearDetectedFrame() { + if (!tracking) { + return; + } + tracking = false; + bracketPaint.setColor(ContextCompat.getColor(getContext(), R.color.qr_scan_frame)); + animateTo(defaultFrame); + } + + private void animateTo(@NonNull RectF target) { + if (frameAnimator != null) { + frameAnimator.cancel(); + } + if (drawFrame.width() <= 0f || drawFrame.height() <= 0f) { + drawFrame.set(target); + invalidate(); + return; + } + animStart.set(drawFrame); + animEnd.set(target); + frameAnimator = ValueAnimator.ofFloat(0f, 1f); + frameAnimator.setDuration(ANIM_MS); + frameAnimator.setInterpolator(new DecelerateInterpolator()); + frameAnimator.addUpdateListener(a -> { + float t = (float) a.getAnimatedValue(); + drawFrame.set( + lerp(animStart.left, animEnd.left, t), + lerp(animStart.top, animEnd.top, t), + lerp(animStart.right, animEnd.right, t), + lerp(animStart.bottom, animEnd.bottom, t)); + invalidate(); + }); + frameAnimator.start(); + } + + private void enforceMinSize(@NonNull RectF rect) { + float w = rect.width(); + float h = rect.height(); + if (w >= minSidePx && h >= minSidePx) { + return; + } + float cx = rect.centerX(); + float cy = rect.centerY(); + float half = Math.max(minSidePx * 0.5f, Math.max(w, h) * 0.5f); + rect.set(cx - half, cy - half, cx + half, cy + half); + } + + private void clampToView(@NonNull RectF rect) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) { + return; + } + if (rect.left < 0f) { + rect.offset(-rect.left, 0f); + } + if (rect.top < 0f) { + rect.offset(0f, -rect.top); + } + if (rect.right > w) { + rect.offset(w - rect.right, 0f); + } + if (rect.bottom > h) { + rect.offset(0f, h - rect.bottom); + } + } + + private static float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0 || drawFrame.width() <= 0f) { + return; + } + + dimPath.reset(); + dimPath.setFillType(Path.FillType.EVEN_ODD); + dimPath.addRect(0f, 0f, w, h, Path.Direction.CW); + dimPath.addRect(drawFrame, Path.Direction.CW); + canvas.drawPath(dimPath, dimPaint); + drawCornerBrackets(canvas, drawFrame); + if (focusAlpha > 0.02f && focusRadius > 0f) { + focusPaint.setAlpha((int) (255f * focusAlpha)); + canvas.drawCircle(focusX, focusY, focusRadius, focusPaint); + } + } + + private void drawCornerBrackets(@NonNull Canvas canvas, @NonNull RectF r) { + float len = Math.min(cornerLengthPx, Math.min(r.width(), r.height()) * 0.35f); + float l = r.left; + float t = r.top; + float ri = r.right; + float b = r.bottom; + + canvas.drawLine(l, t + len, l, t, bracketPaint); + canvas.drawLine(l, t, l + len, t, bracketPaint); + + canvas.drawLine(ri - len, t, ri, t, bracketPaint); + canvas.drawLine(ri, t, ri, t + len, bracketPaint); + + canvas.drawLine(l, b - len, l, b, bracketPaint); + canvas.drawLine(l, b, l + len, b, bracketPaint); + + canvas.drawLine(ri - len, b, ri, b, bracketPaint); + canvas.drawLine(ri, b, ri, b - len, bracketPaint); + } + + @Override + protected void onDetachedFromWindow() { + if (frameAnimator != null) { + frameAnimator.cancel(); + frameAnimator = null; + } + if (focusAnimator != null) { + focusAnimator.cancel(); + focusAnimator = null; + } + super.onDetachedFromWindow(); + } +} diff --git a/app/src/main/res/layout/activity_qr_scan.xml b/app/src/main/res/layout/activity_qr_scan.xml index 3abe5d6..1e8dff8 100644 --- a/app/src/main/res/layout/activity_qr_scan.xml +++ b/app/src/main/res/layout/activity_qr_scan.xml @@ -8,6 +8,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + Лицензии Сканировать QR Сканер QR - Наведите на QR — ссылки подтверждения входа откроются в приложении + Наведите на QR — коснитесь для фокуса · ссылки входа откроются в приложении Для сканирования QR нужен доступ к камере Не удалось запустить камеру Android Cast diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 39edf8f..2097161 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -13,4 +13,7 @@ #AA000000 #FFFFFFFF #66000000 + #99000000 + #FFFFFFFF + #FF34D399 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07743a4..244652d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -213,7 +213,7 @@ Licenses Scan QR code Scan QR code - Point at a QR code — sign-in approval links open inside the app + Point at a QR code — tap to refocus · sign-in links open in-app Camera permission is required to scan QR codes Could not start the camera Android Cast