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:
@@ -1,11 +1,12 @@
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
// Activity di avvio. La configurazione dell'app (tipo App, font) è nella
|
||||
// MainApplication (AvaloniaAndroidApplication<App>). Qui basta il launcher.
|
||||
[Activity(
|
||||
Label = "Palladium Wallet",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
@@ -13,4 +14,24 @@ namespace PalladiumWallet.Mobile;
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
public class MainActivity : AvaloniaMainActivity
|
||||
{
|
||||
internal const int ScanRequestCode = 9001;
|
||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||
internal static MainActivity? Current;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
Current = this;
|
||||
}
|
||||
|
||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||
{
|
||||
base.OnActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == ScanRequestCode)
|
||||
{
|
||||
var text = resultCode == Result.Ok ? data?.GetStringExtra(ScannerActivity.ResultKey) : null;
|
||||
ScanTcs?.TrySetResult(text);
|
||||
ScanTcs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Runtime;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
@@ -21,10 +24,21 @@ public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWalle
|
||||
|
||||
public override void OnCreate()
|
||||
{
|
||||
// Storage sandbox dell'app: wallet, configurazione e certificati vivono
|
||||
// qui. Impostato prima dell'init Avalonia (che crea il ViewModel e decide
|
||||
// se mostrare lo step "scegli cartella dati" del wizard).
|
||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
||||
|
||||
// Registra lo scanner QR: apre ScannerActivity e ne attende il risultato.
|
||||
PlatformServices.ScanQrAsync = async () =>
|
||||
{
|
||||
var activity = MainActivity.Current;
|
||||
if (activity == null) return null;
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
MainActivity.ScanTcs = tcs;
|
||||
activity.StartActivityForResult(
|
||||
new Intent(activity, typeof(ScannerActivity)),
|
||||
MainActivity.ScanRequestCode);
|
||||
return await tcs.Task;
|
||||
};
|
||||
|
||||
base.OnCreate();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -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