1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-07-29 02:07:35 +03:00

Add QR scan target frame and tap-to-refocus in scanner.

Track detected QR bounds with animated corner brackets; tap overlay to trigger CameraX AF/AE with focus pulse feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 15:40:38 +02:00
parent 194e558260
commit cd520cadd1
7 changed files with 407 additions and 24 deletions

View File

@@ -2,8 +2,11 @@ package com.foxx.androidcast.qr;
import android.Manifest; import android.Manifest;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle; import android.os.Bundle;
import android.util.Size; import android.util.Size;
import android.view.MotionEvent;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
@@ -11,8 +14,13 @@ import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.camera.core.Camera;
import androidx.camera.core.CameraSelector; import androidx.camera.core.CameraSelector;
import androidx.camera.core.FocusMeteringAction;
import androidx.camera.core.ImageAnalysis; 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.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider; import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView; 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.barcode.common.Barcode;
import com.google.mlkit.vision.common.InputImage; import com.google.mlkit.vision.common.InputImage;
import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
/** Camera QR scanner — routes our auth URLs in-app. */ /** Camera QR scanner — routes our auth URLs in-app. */
public final class QrScanActivity extends AppCompatActivity { public final class QrScanActivity extends AppCompatActivity {
private static final int CLEAR_FRAME_AFTER_EMPTY_FRAMES = 8;
private final AtomicBoolean handled = new AtomicBoolean(false); private final AtomicBoolean handled = new AtomicBoolean(false);
private int emptyFrameStreak;
private ExecutorService cameraExecutor; private ExecutorService cameraExecutor;
private BarcodeScanner scanner; private BarcodeScanner scanner;
@Nullable
private Camera camera;
@Nullable
private PreviewView previewView;
@Nullable
private QrScanFrameOverlay frameOverlay;
private final ActivityResultLauncher<String> cameraPermission = registerForActivityResult( private final ActivityResultLauncher<String> cameraPermission = registerForActivityResult(
new ActivityResultContracts.RequestPermission(), new ActivityResultContracts.RequestPermission(),
@@ -59,6 +78,16 @@ public final class QrScanActivity extends AppCompatActivity {
getSupportActionBar().setTitle(R.string.qr_scan_title); 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(); cameraExecutor = Executors.newSingleThreadExecutor();
BarcodeScannerOptions options = new BarcodeScannerOptions.Builder() BarcodeScannerOptions options = new BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE) .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
@@ -74,12 +103,15 @@ public final class QrScanActivity extends AppCompatActivity {
} }
private void startCamera() { private void startCamera() {
PreviewView previewView = findViewById(R.id.qr_preview); PreviewView preview = previewView;
if (preview == null) {
return;
}
ListenableFuture<ProcessCameraProvider> future = ProcessCameraProvider.getInstance(this); ListenableFuture<ProcessCameraProvider> future = ProcessCameraProvider.getInstance(this);
future.addListener(() -> { future.addListener(() -> {
try { try {
ProcessCameraProvider provider = future.get(); ProcessCameraProvider provider = future.get();
bindCamera(provider, previewView); bindCamera(provider, preview);
} catch (Exception e) { } catch (Exception e) {
Toast.makeText(this, R.string.qr_scan_camera_error, Toast.LENGTH_LONG).show(); Toast.makeText(this, R.string.qr_scan_camera_error, Toast.LENGTH_LONG).show();
finish(); finish();
@@ -96,30 +128,100 @@ public final class QrScanActivity extends AppCompatActivity {
.setTargetResolution(new Size(1280, 720)) .setTargetResolution(new Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build(); .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<Barcode> barcodes, @NonNull ImageProxy imageProxy) {
try {
if (handled.get()) { if (handled.get()) {
imageProxy.close();
return; return;
} }
InputImage image = InputImage.fromMediaImage( PreviewView preview = previewView;
imageProxy.getImage(), imageProxy.getImageInfo().getRotationDegrees()); QrScanFrameOverlay overlay = frameOverlay;
scanner.process(image) Barcode best = pickBestBarcode(barcodes);
.addOnSuccessListener(barcodes -> { if (best == null) {
for (Barcode barcode : barcodes) { emptyFrameStreak++;
String raw = barcode.getRawValue(); if (emptyFrameStreak >= CLEAR_FRAME_AFTER_EMPTY_FRAMES && overlay != null) {
if (raw != null && !raw.isEmpty() && handled.compareAndSet(false, true)) { overlay.clearDetectedFrame();
runOnUiThread(() -> { }
AcAppLinkRouter.open(QrScanActivity.this, raw); return;
finish(); }
}); emptyFrameStreak = 0;
break; Rect box = best.getBoundingBox();
} if (box != null && preview != null && overlay != null && preview.getWidth() > 0) {
} RectF mapped = QrScanCoordinateMapper.mapBarcodeToPreview(box, imageProxy, preview);
}) overlay.setDetectedFrame(mapped);
.addOnCompleteListener(task -> imageProxy.close()); }
}); 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<Barcode> 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 @Override
@@ -130,6 +232,7 @@ public final class QrScanActivity extends AppCompatActivity {
if (cameraExecutor != null) { if (cameraExecutor != null) {
cameraExecutor.shutdown(); cameraExecutor.shutdown();
} }
camera = null;
super.onDestroy(); super.onDestroy();
} }

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -8,6 +8,12 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<com.foxx.androidcast.qr.QrScanFrameOverlay
android:id="@+id/qr_frame_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -194,7 +194,7 @@
<string name="btn_licenses">Лицензии</string> <string name="btn_licenses">Лицензии</string>
<string name="btn_scan_qr">Сканировать QR</string> <string name="btn_scan_qr">Сканировать QR</string>
<string name="qr_scan_title">Сканер QR</string> <string name="qr_scan_title">Сканер QR</string>
<string name="qr_scan_hint">Наведите на QR — ссылки подтверждения входа откроются в приложении</string> <string name="qr_scan_hint">Наведите на QR — коснитесь для фокуса · ссылки входа откроются в приложении</string>
<string name="qr_scan_camera_denied">Для сканирования QR нужен доступ к камере</string> <string name="qr_scan_camera_denied">Для сканирования QR нужен доступ к камере</string>
<string name="qr_scan_camera_error">Не удалось запустить камеру</string> <string name="qr_scan_camera_error">Не удалось запустить камеру</string>
<string name="in_app_web_title">Android Cast</string> <string name="in_app_web_title">Android Cast</string>

View File

@@ -13,4 +13,7 @@
<color name="settings_spinner_text_shadow">#AA000000</color> <color name="settings_spinner_text_shadow">#AA000000</color>
<color name="settings_spinner_selected_underline">#FFFFFFFF</color> <color name="settings_spinner_selected_underline">#FFFFFFFF</color>
<color name="settings_app_info_divider">#66000000</color> <color name="settings_app_info_divider">#66000000</color>
<color name="qr_scan_dim">#99000000</color>
<color name="qr_scan_frame">#FFFFFFFF</color>
<color name="qr_scan_frame_detected">#FF34D399</color>
</resources> </resources>

View File

@@ -213,7 +213,7 @@
<string name="btn_licenses">Licenses</string> <string name="btn_licenses">Licenses</string>
<string name="btn_scan_qr">Scan QR code</string> <string name="btn_scan_qr">Scan QR code</string>
<string name="qr_scan_title">Scan QR code</string> <string name="qr_scan_title">Scan QR code</string>
<string name="qr_scan_hint">Point at a QR code — sign-in approval links open inside the app</string> <string name="qr_scan_hint">Point at a QR code — tap to refocus · sign-in links open in-app</string>
<string name="qr_scan_camera_denied">Camera permission is required to scan QR codes</string> <string name="qr_scan_camera_denied">Camera permission is required to scan QR codes</string>
<string name="qr_scan_camera_error">Could not start the camera</string> <string name="qr_scan_camera_error">Could not start the camera</string>
<string name="in_app_web_title">Android Cast</string> <string name="in_app_web_title">Android Cast</string>