on master help sign in
Add QR code scanning for public key input (camera on iOS, file picker on desktop)
commit819fedbc5123070bc6fac610163bded5b666e688 authoriko <mail@iko.soy> dateTue 21 Apr 2026, 21:49 (6 months ago) parentadc8b7f
5 files changed+187
client/App.fs+66 client/AppTypes.fs+3 client/Platform/iOS/QRScanner.fs+113 client/Skrepka.fsproj+3 client/ViewAddContact.fs+2
client/App.fs+66
@@ -368,6 +368,15 @@ module App =
368368
369369 | KeyboardHeightChanged h -> { model with KeyboardHeight = h }, []
370370
371+ | DoScanQR -> model, [ CmdScanQR ]
372+
373+ | QRScanned(Some text) ->
374+ match model.Page with
375+ | AddContact _ -> { model with Page = AddContact(text) }, []
376+ | _ -> model, []
377+
378+ | QRScanned None -> model, []
379+
371380 // ── mapCmd ──
372381
373382 let private log msg = eprintfn $"[skrepka] {msg}"
@@ -417,6 +426,18 @@ module App =
417426 data.ToArray()
418427
419428#if MOBILE
429+ let private scanQRCmd =
430+ Cmd.ofEffect (fun dispatch ->
431+ task {
432+ try
433+ let! result = Skrepka.iOS.QRScanner.scan ()
434+ dispatch (QRScanned result)
435+ with ex ->
436+ log $"QR scan error: {ex.Message}"
437+ dispatch (QRScanned None)
438+ }
439+ |> ignore)
440+
420441 let private pickPhotoCmd =
421442 Cmd.ofEffect (fun dispatch ->
422443 task {
@@ -432,6 +453,49 @@ module App =
432453 }
433454 |> ignore)
434455#else
456+ let private scanQRCmd =
457+ Cmd.ofEffect (fun dispatch ->
458+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
459+ task {
460+ try
461+ match getStorageProvider () with
462+ | Some sp ->
463+ let opts =
464+ Avalonia.Platform.Storage.FilePickerOpenOptions(
465+ AllowMultiple = false,
466+ Title = "Select QR Code Image",
467+ FileTypeFilter =
468+ [ Avalonia.Platform.Storage.FilePickerFileType(
469+ "Images",
470+ Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
471+ ) ]
472+ )
473+
474+ let! files = sp.OpenFilePickerAsync(opts)
475+
476+ match files |> Seq.tryHead with
477+ | None -> dispatch (QRScanned None)
478+ | Some file ->
479+ use! stream = file.OpenReadAsync()
480+ use ms = new MemoryStream()
481+ do! stream.CopyToAsync(ms)
482+
483+ use bitmap = SkiaSharp.SKBitmap.Decode(ms.ToArray())
484+ let reader = ZXing.SkiaSharp.BarcodeReader()
485+ let result = reader.Decode(bitmap)
486+
487+ if isNull result then
488+ dispatch (QRScanned None)
489+ else
490+ dispatch (QRScanned(Some result.Text))
491+ | None -> dispatch (QRScanned None)
492+ with ex ->
493+ log $"QR scan error: {ex.Message}"
494+ dispatch (QRScanned None)
495+ }
496+ :> System.Threading.Tasks.Task)
497+ |> ignore)
498+
435499 let private pickPhotoCmd =
436500 Cmd.ofEffect (fun dispatch ->
437501 Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
@@ -635,6 +699,8 @@ module App =
635699
636700 | CmdPickPhoto -> pickPhotoCmd
637701
702+ | CmdScanQR -> scanQRCmd
703+
638704 | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile |> Async.Start)
639705
640706 | CmdSubscribeKeyboard ->
client/AppTypes.fs+3
@@ -86,6 +86,8 @@ module AppTypes =
8686 | Search of string
8787 | TokenExpired
8888 | KeyboardHeightChanged of float
89+ | DoScanQR
90+ | QRScanned of string option
8991
9092 // ── CmdMsg ──
9193
@@ -101,6 +103,7 @@ module AppTypes =
101103 | CmdPickPhoto
102104 | CmdSaveProfile of Profile
103105 | CmdSubscribeKeyboard
106+ | CmdScanQR
104107
105108 // ── Shared Helpers ──
106109
client/Platform/iOS/QRScanner.fsnew file+113
@@ -0,0 +1,113 @@
1+namespace Skrepka.iOS
2+
3+open System
4+open System.Threading.Tasks
5+open Foundation
6+open UIKit
7+open AVFoundation
8+open CoreFoundation
9+
10+type private ScanDelegate(tcs: TaskCompletionSource<string option>, session: AVCaptureSession) =
11+ inherit AVCaptureMetadataOutputObjectsDelegate()
12+
13+ let mutable handled = false
14+
15+ override _.DidOutputMetadataObjects(output, metadataObjects, connection) =
16+ if not handled && metadataObjects.Length > 0 then
17+ match metadataObjects.[0] with
18+ | :? AVMetadataMachineReadableCodeObject as code when code.Type = AVMetadataObjectType.QRCode ->
19+ handled <- true
20+ session.StopRunning()
21+
22+ DispatchQueue.MainQueue.DispatchAsync(fun () ->
23+ let window = UIApplication.SharedApplication.KeyWindow
24+
25+ if not (isNull window) && not (isNull window.RootViewController) then
26+ window.RootViewController.DismissViewController(true, fun () ->
27+ tcs.TrySetResult(Some code.StringValue) |> ignore)
28+ else
29+ tcs.TrySetResult(Some code.StringValue) |> ignore)
30+ | _ -> ()
31+
32+module QRScanner =
33+ let mutable private prevent_gc: obj = null
34+
35+ let private presentScanner (tcs: TaskCompletionSource<string option>) =
36+ let session = new AVCaptureSession()
37+
38+ match AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Video) with
39+ | null -> tcs.TrySetResult None |> ignore
40+ | device ->
41+ let mutable error: NSError = null
42+ let input = AVCaptureDeviceInput.FromDevice(device, &error)
43+
44+ if isNull input then
45+ tcs.TrySetResult None |> ignore
46+ else
47+ session.AddInput(input)
48+
49+ let output = new AVCaptureMetadataOutput()
50+ session.AddOutput(output)
51+
52+ let del = new ScanDelegate(tcs, session)
53+ output.SetDelegate(del, DispatchQueue.MainQueue)
54+ output.MetadataObjectTypes <- AVMetadataObjectType.QRCode
55+
56+ let preview = AVCaptureVideoPreviewLayer.FromSession(session)
57+ preview.VideoGravity <- AVLayerVideoGravity.ResizeAspectFill
58+ preview.Frame <- UIScreen.MainScreen.Bounds
59+
60+ let vc = new UIViewController()
61+ vc.View.BackgroundColor <- UIColor.Black
62+ vc.View.Layer.AddSublayer(preview)
63+
64+ let closeBtn = UIButton.FromType(UIButtonType.System)
65+ closeBtn.SetTitle("Cancel", UIControlState.Normal)
66+ closeBtn.SetTitleColor(UIColor.White, UIControlState.Normal)
67+ closeBtn.Frame <- CoreGraphics.CGRect(20., 60., 100., 44.)
68+
69+ closeBtn.TouchUpInside.Add(fun _ ->
70+ session.StopRunning()
71+
72+ vc.DismissViewController(true, fun () ->
73+ tcs.TrySetResult None |> ignore))
74+
75+ vc.View.AddSubview(closeBtn)
76+
77+ prevent_gc <- box (session, del, preview, vc)
78+
79+ tcs.Task.ContinueWith(fun (_: Task<string option>) -> prevent_gc <- null)
80+ |> ignore
81+
82+ session.StartRunning()
83+
84+ let window = UIApplication.SharedApplication.KeyWindow
85+
86+ if not (isNull window) && not (isNull window.RootViewController) then
87+ vc.ModalPresentationStyle <- UIModalPresentationStyle.FullScreen
88+ window.RootViewController.PresentViewController(vc, true, null)
89+ else
90+ session.StopRunning()
91+ tcs.TrySetResult None |> ignore
92+
93+ let scan () : Task<string option> =
94+ let tcs = TaskCompletionSource<string option>()
95+
96+ Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
97+ let status = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Video)
98+
99+ match status with
100+ | AVAuthorizationStatus.Denied
101+ | AVAuthorizationStatus.Restricted -> tcs.TrySetResult None |> ignore
102+ | AVAuthorizationStatus.NotDetermined ->
103+ AVCaptureDevice.RequestAccessForMediaType(
104+ AVAuthorizationMediaType.Video,
105+ fun granted ->
106+ if granted then
107+ DispatchQueue.MainQueue.DispatchAsync(fun () -> presentScanner tcs)
108+ else
109+ tcs.TrySetResult None |> ignore
110+ )
111+ | _ -> presentScanner tcs)
112+
113+ tcs.Task
client/Skrepka.fsproj+3
@@ -38,6 +38,7 @@
3838 <Compile Include="Labels.fs" />
3939 <Compile Include="Buttons.fs" />
4040 <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
41+ <Compile Include="$(iOSPlatformFolder)QRScanner.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
4142 <Compile Include="Keyboard.fs" />
4243 <Compile Include="AppTypes.fs" />
4344 <Compile Include="Protocol.fs" />
@@ -92,6 +93,7 @@
9293 <Exec Command="$(_PB) -c 'Delete :UILaunchStoryboardName' '$(_Plist)'" ContinueOnError="true" />
9394 <Exec Command="$(_PB) -c 'Add :UILaunchScreen dict' '$(_Plist)'" ContinueOnError="true" />
9495 <!-- Privacy descriptions -->
96+ <Exec Command="$(_PB) -c 'Add :NSCameraUsageDescription string Skrepka needs access to your camera to scan QR codes.' '$(_Plist)'" ContinueOnError="true" />
9597 <Exec Command="$(_PB) -c 'Add :NSPhotoLibraryUsageDescription string Skrepka needs access to your photo library to set a profile photo.' '$(_Plist)'" ContinueOnError="true" />
9698 <!-- App icon (fallback when actool produces empty output) -->
9799 <Exec Command="$(_PB) -c 'Add :CFBundleIcons dict' '$(_Plist)'" ContinueOnError="true" />
@@ -130,6 +132,7 @@
130132 <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.2.26159.112" />
131133 <PackageReference Include="K4os.Compression.LZ4" Version="1.3.8" />
132134 <PackageReference Include="QRCoder" Version="1.6.0" />
135+ <PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" Condition="$(AvaloniaPlatform) == 'Desktop'" />
133136 </ItemGroup>
134137
135138</Project>
client/ViewAddContact.fs+2
@@ -39,6 +39,8 @@ module ViewAddContact =
3939
4040 h2 "Enter someone else's address"
4141
42+ smallButton Secondary "Scan QR Code" DoScanQR
43+
4244 let cpkTrimmed: string = cpk.Trim()
4345
4446 let isValid =