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:
@@ -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<String> 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<ProcessCameraProvider> 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 -> {
|
||||
for (Barcode barcode : barcodes) {
|
||||
String raw = barcode.getRawValue();
|
||||
.addOnSuccessListener(barcodes -> runOnUiThread(() -> onBarcodesDetected(barcodes, imageProxy)))
|
||||
.addOnFailureListener(e -> imageProxy.close());
|
||||
}
|
||||
|
||||
private void onBarcodesDetected(@NonNull List<Barcode> barcodes, @NonNull ImageProxy imageProxy) {
|
||||
try {
|
||||
if (handled.get()) {
|
||||
return;
|
||||
}
|
||||
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)) {
|
||||
runOnUiThread(() -> {
|
||||
AcAppLinkRouter.open(QrScanActivity.this, raw);
|
||||
finish();
|
||||
});
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
imageProxy.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addOnCompleteListener(task -> 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
|
||||
@@ -130,6 +232,7 @@ public final class QrScanActivity extends AppCompatActivity {
|
||||
if (cameraExecutor != null) {
|
||||
cameraExecutor.shutdown();
|
||||
}
|
||||
camera = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
android:layout_width="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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<string name="btn_licenses">Лицензии</string>
|
||||
<string name="btn_scan_qr">Сканировать 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_error">Не удалось запустить камеру</string>
|
||||
<string name="in_app_web_title">Android Cast</string>
|
||||
|
||||
@@ -13,4 +13,7 @@
|
||||
<color name="settings_spinner_text_shadow">#AA000000</color>
|
||||
<color name="settings_spinner_selected_underline">#FFFFFFFF</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>
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
<string name="btn_licenses">Licenses</string>
|
||||
<string name="btn_scan_qr">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_error">Could not start the camera</string>
|
||||
<string name="in_app_web_title">Android Cast</string>
|
||||
|
||||
Reference in New Issue
Block a user