feat(android): QR code scanner for Send address field
Camera2 + ZXing.Net 0.16.9 activity (ScannerActivity) that captures JPEG frames at ~2.5 fps and decodes QR codes without Xamarin dependencies. Result is returned to MainActivity via OnActivityResult/TaskCompletionSource. PlatformServices.ScanQrAsync seam wires Android head to shared App layer; ScanQrCommand in the Send ViewModel strips BIP21 URI scheme/query parameters. CAMERA permission and uses-feature added to AndroidManifest.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Graphics;
|
||||
using Android.Hardware.Camera2;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Android.Widget;
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
using AndroidResult = Android.App.Result;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
/// <summary>
|
||||
/// Activity full-screen per la scansione QR via Camera2 + ZXing.Net.
|
||||
/// Torna a MainActivity via SetResult con l'extra "qr" = testo del codice.
|
||||
/// </summary>
|
||||
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
||||
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
||||
internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureListener
|
||||
{
|
||||
internal const string ResultKey = "qr";
|
||||
private const int PermReq = 1;
|
||||
|
||||
private HandlerThread? _bgThread;
|
||||
private Handler? _bgHandler;
|
||||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private ImageReader? _imageReader;
|
||||
private SurfaceTexture? _surfaceTex;
|
||||
private bool _permOk;
|
||||
private volatile bool _done;
|
||||
private long _lastDecode;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
Window!.SetFlags(WindowManagerFlags.Fullscreen, WindowManagerFlags.Fullscreen);
|
||||
|
||||
var root = new FrameLayout(this);
|
||||
|
||||
var preview = new TextureView(this) { SurfaceTextureListener = this };
|
||||
root.AddView(preview, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MatchParent, FrameLayout.LayoutParams.MatchParent));
|
||||
|
||||
var hint = new TextView(this) { Text = "Inquadra un codice QR" };
|
||||
hint.SetTextColor(Color.White);
|
||||
hint.SetBackgroundColor(Color.ParseColor("#AA000000"));
|
||||
hint.SetPadding(24, 12, 24, 12);
|
||||
root.AddView(hint, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Top | GravityFlags.CenterHorizontal) { TopMargin = 60 });
|
||||
|
||||
var cancel = new Button(this) { Text = "Annulla" };
|
||||
cancel.Click += (_, _) => Finish();
|
||||
root.AddView(cancel, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Bottom | GravityFlags.CenterHorizontal) { BottomMargin = 80 });
|
||||
|
||||
SetContentView(root);
|
||||
|
||||
_bgThread = new HandlerThread("CamBg");
|
||||
_bgThread.Start();
|
||||
_bgHandler = new Handler(_bgThread.Looper!);
|
||||
|
||||
if (CheckSelfPermission(Android.Manifest.Permission.Camera) == Android.Content.PM.Permission.Granted)
|
||||
_permOk = true;
|
||||
else
|
||||
RequestPermissions([Android.Manifest.Permission.Camera], PermReq);
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int req, string[] perms, Android.Content.PM.Permission[] grants)
|
||||
{
|
||||
_permOk = grants.Length > 0 && grants[0] == Android.Content.PM.Permission.Granted;
|
||||
if (_permOk) TryOpen(); else Finish();
|
||||
}
|
||||
|
||||
public void OnSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { _surfaceTex = st; TryOpen(); }
|
||||
public bool OnSurfaceTextureDestroyed(SurfaceTexture st) { CloseCamera(); return true; }
|
||||
public void OnSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { }
|
||||
public void OnSurfaceTextureUpdated(SurfaceTexture st) { }
|
||||
|
||||
private void TryOpen() { if (_permOk && _surfaceTex != null) OpenCamera(_surfaceTex); }
|
||||
|
||||
private void OpenCamera(SurfaceTexture st)
|
||||
{
|
||||
var mgr = (CameraManager)GetSystemService(CameraService)!;
|
||||
|
||||
var ids = mgr.GetCameraIdList()!;
|
||||
string cameraId = ids[0];
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var chars = mgr.GetCameraCharacteristics(id)!;
|
||||
if (chars.Get(CameraCharacteristics.LensFacing) is Java.Lang.Integer f
|
||||
&& f.IntValue() == (int)LensFacing.Back)
|
||||
{ cameraId = id; break; }
|
||||
}
|
||||
|
||||
var map = mgr.GetCameraCharacteristics(cameraId)!
|
||||
.Get(CameraCharacteristics.ScalerStreamConfigurationMap)
|
||||
as Android.Hardware.Camera2.Params.StreamConfigurationMap;
|
||||
var allSizes = map!.GetOutputSizes((int)ImageFormatType.Jpeg)!;
|
||||
var size = allSizes.OrderBy(s => Math.Abs(s.Width - 640)).First();
|
||||
|
||||
st.SetDefaultBufferSize(size.Width, size.Height);
|
||||
_imageReader = ImageReader.NewInstance(size.Width, size.Height, ImageFormatType.Jpeg, 2);
|
||||
_imageReader.SetOnImageAvailableListener(new ImageListener(this), _bgHandler);
|
||||
|
||||
mgr.OpenCamera(cameraId, new OpenCb(this, st), _bgHandler);
|
||||
}
|
||||
|
||||
private void CloseCamera()
|
||||
{
|
||||
_session?.Close(); _session = null;
|
||||
_camera?.Close(); _camera = null;
|
||||
_imageReader?.Close(); _imageReader = null;
|
||||
_bgThread?.QuitSafely();
|
||||
}
|
||||
|
||||
internal void OnCameraOpened(CameraDevice dev, SurfaceTexture st)
|
||||
{
|
||||
_camera = dev;
|
||||
var prevSurf = new Surface(st);
|
||||
var capSurf = _imageReader!.Surface!;
|
||||
#pragma warning disable CA1422 // CreateCaptureSession(IList,...) obsoleted on API 30; replacement needs API 28, our min is 23
|
||||
dev.CreateCaptureSession([prevSurf, capSurf], new SessionCb(this, prevSurf, capSurf), _bgHandler);
|
||||
#pragma warning restore CA1422
|
||||
}
|
||||
|
||||
internal void OnSessionReady(CameraCaptureSession session, Surface prev, Surface cap)
|
||||
{
|
||||
_session = session;
|
||||
var req = _camera!.CreateCaptureRequest(CameraTemplate.Preview);
|
||||
req.AddTarget(prev);
|
||||
req.AddTarget(cap);
|
||||
session.SetRepeatingRequest(req.Build()!, null, _bgHandler);
|
||||
}
|
||||
|
||||
internal void TryDecode(ImageReader reader)
|
||||
{
|
||||
if (_done) return;
|
||||
var image = reader.AcquireLatestImage();
|
||||
if (image == null) return;
|
||||
try
|
||||
{
|
||||
var now = SystemClock.ElapsedRealtime();
|
||||
if (now - _lastDecode < 400) return; // max ~2.5 fps
|
||||
_lastDecode = now;
|
||||
|
||||
var buf = image.GetPlanes()![0].Buffer!;
|
||||
var bytes = new byte[buf.Remaining()];
|
||||
buf.Get(bytes);
|
||||
|
||||
var bmp = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length);
|
||||
if (bmp == null) return;
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var pixels = new int[w * h];
|
||||
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
||||
bmp.Recycle();
|
||||
|
||||
// ARGB int[] → RGB byte[] per ZXing.RGBLuminanceSource
|
||||
var rgb = new byte[pixels.Length * 3];
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
rgb[i * 3] = (byte)(pixels[i] >> 16);
|
||||
rgb[i * 3 + 1] = (byte)(pixels[i] >> 8);
|
||||
rgb[i * 3 + 2] = (byte) pixels[i];
|
||||
}
|
||||
|
||||
var source = new RGBLuminanceSource(rgb, w, h, RGBLuminanceSource.BitmapFormat.RGB24);
|
||||
var zreader = new BarcodeReaderGeneric { AutoRotate = true };
|
||||
zreader.Options.PossibleFormats = [BarcodeFormat.QR_CODE];
|
||||
zreader.Options.TryHarder = true;
|
||||
var result = zreader.Decode(source);
|
||||
if (result == null) return;
|
||||
|
||||
_done = true;
|
||||
RunOnUiThread(() =>
|
||||
{
|
||||
var intent = new Intent().PutExtra(ResultKey, result.Text);
|
||||
SetResult(AndroidResult.Ok, intent);
|
||||
Finish();
|
||||
});
|
||||
}
|
||||
finally { image.Close(); }
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
CloseCamera();
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
// ── Callback helpers ─────────────────────────────────────────────────
|
||||
|
||||
private sealed class OpenCb(ScannerActivity a, SurfaceTexture st) : CameraDevice.StateCallback
|
||||
{
|
||||
public override void OnOpened(CameraDevice cam) => a.OnCameraOpened(cam, st);
|
||||
public override void OnDisconnected(CameraDevice cam) { cam.Close(); a.Finish(); }
|
||||
public override void OnError(CameraDevice cam, CameraError err) { cam.Close(); a.Finish(); }
|
||||
}
|
||||
|
||||
private sealed class SessionCb(ScannerActivity a, Surface prev, Surface cap) : CameraCaptureSession.StateCallback
|
||||
{
|
||||
public override void OnConfigured(CameraCaptureSession s) => a.OnSessionReady(s, prev, cap);
|
||||
public override void OnConfigureFailed(CameraCaptureSession s) => a.Finish();
|
||||
}
|
||||
|
||||
private sealed class ImageListener(ScannerActivity a) : Java.Lang.Object, ImageReader.IOnImageAvailableListener
|
||||
{
|
||||
// Interface declares ImageReader? but reader is never null in practice
|
||||
public void OnImageAvailable(ImageReader? reader) => a.TryDecode(reader!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user