diff --git a/src/App.Android/MainActivity.cs b/src/App.Android/MainActivity.cs index f913dae..0d0cd3c 100644 --- a/src/App.Android/MainActivity.cs +++ b/src/App.Android/MainActivity.cs @@ -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). 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? 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; + } + } } diff --git a/src/App.Android/MainApplication.cs b/src/App.Android/MainApplication.cs index af11f2f..7708e01 100644 --- a/src/App.Android/MainApplication.cs +++ b/src/App.Android/MainApplication.cs @@ -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 + { + var activity = MainActivity.Current; + if (activity == null) return null; + var tcs = new TaskCompletionSource(); + MainActivity.ScanTcs = tcs; + activity.StartActivityForResult( + new Intent(activity, typeof(ScannerActivity)), + MainActivity.ScanRequestCode); + return await tcs.Task; + }; + base.OnCreate(); } diff --git a/src/App.Android/PalladiumWallet.App.Android.csproj b/src/App.Android/PalladiumWallet.App.Android.csproj index 6e4bf31..32417be 100644 --- a/src/App.Android/PalladiumWallet.App.Android.csproj +++ b/src/App.Android/PalladiumWallet.App.Android.csproj @@ -23,6 +23,7 @@ + diff --git a/src/App.Android/Properties/AndroidManifest.xml b/src/App.Android/Properties/AndroidManifest.xml index 1c6a4c2..c21ccc4 100644 --- a/src/App.Android/Properties/AndroidManifest.xml +++ b/src/App.Android/Properties/AndroidManifest.xml @@ -5,4 +5,6 @@ [Application] su MainApplication. usesCleartextTraffic resta al default (bloccato su Android 9+): il wallet preferisce comunque TLS. --> + + diff --git a/src/App.Android/ScannerActivity.cs b/src/App.Android/ScannerActivity.cs new file mode 100644 index 0000000..9c12bf5 --- /dev/null +++ b/src/App.Android/ScannerActivity.cs @@ -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; + +/// +/// Activity full-screen per la scansione QR via Camera2 + ZXing.Net. +/// Torna a MainActivity via SetResult con l'extra "qr" = testo del codice. +/// +[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!); + } +} diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index 1a50867..de6eb77 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -183,6 +183,7 @@ public sealed class Loc ["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.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"], // Stato connessione diff --git a/src/App/Services/PlatformServices.cs b/src/App/Services/PlatformServices.cs new file mode 100644 index 0000000..06f69f4 --- /dev/null +++ b/src/App/Services/PlatformServices.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace PalladiumWallet.App.Services; + +/// +/// Seam per servizi specifici della piattaforma (Android/desktop). +/// Il head Android imposta i delegate in OnCreate; desktop li lascia null. +/// +public static class PlatformServices +{ + /// + /// Apre lo scanner QR nativo e restituisce il testo raw del codice, + /// oppure null se l'utente annulla o lo scanner non è disponibile. + /// + public static Func>? ScanQrAsync { get; set; } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs index d2e341c..bf83d09 100644 --- a/src/App/ViewModels/MainWindowViewModel.Send.cs +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NBitcoin; +using PalladiumWallet.App.Services; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Wallet; @@ -29,6 +30,18 @@ public partial class MainWindowViewModel [ObservableProperty] 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] private async Task PrepareSend() {