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.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
|
using Android.OS;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
// Activity di avvio. La configurazione dell'app (tipo App, font) è nella
|
|
||||||
// MainApplication (AvaloniaAndroidApplication<App>). Qui basta il launcher.
|
|
||||||
[Activity(
|
[Activity(
|
||||||
Label = "Palladium Wallet",
|
Label = "Palladium Wallet",
|
||||||
Theme = "@style/MyTheme.NoActionBar",
|
Theme = "@style/MyTheme.NoActionBar",
|
||||||
@@ -13,4 +14,24 @@ namespace PalladiumWallet.Mobile;
|
|||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||||
public class MainActivity : AvaloniaMainActivity
|
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;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Android.App;
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Runtime;
|
using Android.Runtime;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using PalladiumWallet.App.Services;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
@@ -21,10 +24,21 @@ public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWalle
|
|||||||
|
|
||||||
public override void OnCreate()
|
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;
|
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();
|
base.OnCreate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
||||||
|
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -5,4 +5,6 @@
|
|||||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
[Application] su MainApplication. usesCleartextTraffic resta al default
|
||||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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>
|
</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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@ public sealed class Loc
|
|||||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
||||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
||||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||||
|
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
||||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
||||||
|
|
||||||
// Stato connessione
|
// Stato connessione
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seam per servizi specifici della piattaforma (Android/desktop).
|
||||||
|
/// Il head Android imposta i delegate in OnCreate; desktop li lascia null.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlatformServices
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Apre lo scanner QR nativo e restituisce il testo raw del codice,
|
||||||
|
/// oppure null se l'utente annulla o lo scanner non è disponibile.
|
||||||
|
/// </summary>
|
||||||
|
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Services;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Wallet;
|
using PalladiumWallet.Core.Wallet;
|
||||||
@@ -29,6 +30,18 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool hasPendingSend;
|
private bool hasPendingSend;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ScanQr()
|
||||||
|
{
|
||||||
|
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
||||||
|
var raw = await scan();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||||
|
// Gestisce URI tipo "palladium:ADDRESS?amount=X" estraendo solo l'indirizzo
|
||||||
|
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
||||||
|
if (address.Contains('?')) address = address.Split('?')[0];
|
||||||
|
SendTo = address.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task PrepareSend()
|
private async Task PrepareSend()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user