on master help sign in
Add QR code scanning for public key input (camera on iOS, file picker on desktop)
819fedbiko
client/App.fs+66
@@ -368,6 +368,15 @@ module App =
368
368
369 | KeyboardHeightChanged h -> { model with KeyboardHeight = h }, []
369 | KeyboardHeightChanged h -> { model with KeyboardHeight = h }, []
370
370
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+
371 // ── mapCmd ──
380 // ── mapCmd ──
372
381
373 let private log msg = eprintfn $"[skrepka] {msg}"
382 let private log msg = eprintfn $"[skrepka] {msg}"
@@ -417,6 +426,18 @@ module App =
417 data.ToArray()
426 data.ToArray()
418
427
419#if MOBILE
428#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+
420 let private pickPhotoCmd =
441 let private pickPhotoCmd =
421 Cmd.ofEffect (fun dispatch ->
442 Cmd.ofEffect (fun dispatch ->
422 task {
443 task {
@@ -432,6 +453,49 @@ module App =
432 }
453 }
433 |> ignore)
454 |> ignore)
434#else
455#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+
435 let private pickPhotoCmd =
499 let private pickPhotoCmd =
436 Cmd.ofEffect (fun dispatch ->
500 Cmd.ofEffect (fun dispatch ->
437 Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
501 Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
@@ -635,6 +699,8 @@ module App =
635
699
636 | CmdPickPhoto -> pickPhotoCmd
700 | CmdPickPhoto -> pickPhotoCmd
637
701
702+ | CmdScanQR -> scanQRCmd
703+
638 | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile |> Async.Start)
704 | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile |> Async.Start)
639
705
640 | CmdSubscribeKeyboard ->
706 | CmdSubscribeKeyboard ->
client/AppTypes.fs+3
@@ -86,6 +86,8 @@ module AppTypes =
86 | Search of string
86 | Search of string
87 | TokenExpired
87 | TokenExpired
88 | KeyboardHeightChanged of float
88 | KeyboardHeightChanged of float
89+ | DoScanQR
90+ | QRScanned of string option
89
91
90 // ── CmdMsg ──
92 // ── CmdMsg ──
91
93
@@ -101,6 +103,7 @@ module AppTypes =
101 | CmdPickPhoto
103 | CmdPickPhoto
102 | CmdSaveProfile of Profile
104 | CmdSaveProfile of Profile
103 | CmdSubscribeKeyboard
105 | CmdSubscribeKeyboard
106+ | CmdScanQR
104
107
105 // ── Shared Helpers ──
108 // ── Shared Helpers ──
106
109
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 @@
38 <Compile Include="Labels.fs" />
38 <Compile Include="Labels.fs" />
39 <Compile Include="Buttons.fs" />
39 <Compile Include="Buttons.fs" />
40 <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
40 <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
41+ <Compile Include="$(iOSPlatformFolder)QRScanner.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
41 <Compile Include="Keyboard.fs" />
42 <Compile Include="Keyboard.fs" />
42 <Compile Include="AppTypes.fs" />
43 <Compile Include="AppTypes.fs" />
43 <Compile Include="Protocol.fs" />
44 <Compile Include="Protocol.fs" />
@@ -92,6 +93,7 @@
92 <Exec Command="$(_PB) -c 'Delete :UILaunchStoryboardName' '$(_Plist)'" ContinueOnError="true" />
93 <Exec Command="$(_PB) -c 'Delete :UILaunchStoryboardName' '$(_Plist)'" ContinueOnError="true" />
93 <Exec Command="$(_PB) -c 'Add :UILaunchScreen dict' '$(_Plist)'" ContinueOnError="true" />
94 <Exec Command="$(_PB) -c 'Add :UILaunchScreen dict' '$(_Plist)'" ContinueOnError="true" />
94 <!-- Privacy descriptions -->
95 <!-- Privacy descriptions -->
96+ <Exec Command="$(_PB) -c 'Add :NSCameraUsageDescription string Skrepka needs access to your camera to scan QR codes.' '$(_Plist)'" ContinueOnError="true" />
95 <Exec Command="$(_PB) -c 'Add :NSPhotoLibraryUsageDescription string Skrepka needs access to your photo library to set a profile photo.' '$(_Plist)'" ContinueOnError="true" />
97 <Exec Command="$(_PB) -c 'Add :NSPhotoLibraryUsageDescription string Skrepka needs access to your photo library to set a profile photo.' '$(_Plist)'" ContinueOnError="true" />
96 <!-- App icon (fallback when actool produces empty output) -->
98 <!-- App icon (fallback when actool produces empty output) -->
97 <Exec Command="$(_PB) -c 'Add :CFBundleIcons dict' '$(_Plist)'" ContinueOnError="true" />
99 <Exec Command="$(_PB) -c 'Add :CFBundleIcons dict' '$(_Plist)'" ContinueOnError="true" />
@@ -130,6 +132,7 @@
130 <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.2.26159.112" />
132 <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.2.26159.112" />
131 <PackageReference Include="K4os.Compression.LZ4" Version="1.3.8" />
133 <PackageReference Include="K4os.Compression.LZ4" Version="1.3.8" />
132 <PackageReference Include="QRCoder" Version="1.6.0" />
134 <PackageReference Include="QRCoder" Version="1.6.0" />
135+ <PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" Condition="$(AvaloniaPlatform) == 'Desktop'" />
133 </ItemGroup>
136 </ItemGroup>
134
137
135</Project>
138</Project>
client/ViewAddContact.fs+2
@@ -39,6 +39,8 @@ module ViewAddContact =
39
39
40 h2 "Enter someone else's address"
40 h2 "Enter someone else's address"
41
41
42+ smallButton Secondary "Scan QR Code" DoScanQR
43+
42 let cpkTrimmed: string = cpk.Trim()
44 let cpkTrimmed: string = cpk.Trim()
43
45
44 let isValid =
46 let isValid =