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