mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(ios): enable QR and NFC invitation share/receive
Wire Core NFC entitlements and implement invitation ticket export, QR presentation, and NDEF read/write on iOS so send/receive parity matches Android.
This commit is contained in:
@@ -296,6 +296,7 @@
|
|||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
@@ -303,6 +304,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -318,6 +321,10 @@
|
|||||||
SystemConfiguration,
|
SystemConfiguration,
|
||||||
"-framework",
|
"-framework",
|
||||||
Network,
|
Network,
|
||||||
|
"-framework",
|
||||||
|
CoreNFC,
|
||||||
|
"-framework",
|
||||||
|
AVFoundation,
|
||||||
);
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -331,6 +338,7 @@
|
|||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
@@ -338,6 +346,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -353,6 +363,10 @@
|
|||||||
SystemConfiguration,
|
SystemConfiguration,
|
||||||
"-framework",
|
"-framework",
|
||||||
Network,
|
Network,
|
||||||
|
"-framework",
|
||||||
|
CoreNFC,
|
||||||
|
"-framework",
|
||||||
|
AVFoundation,
|
||||||
);
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||||
|
<key>NFCReaderUsageDescription</key>
|
||||||
|
<string>VniDrop uses NFC to read transfer invitation tags.</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
10
iosApp/iosApp/vnidrop.entitlements
Normal file
10
iosApp/iosApp/vnidrop.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
|
<array>
|
||||||
|
<string>NDEF</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,30 +2,91 @@ package com.vnidrop.app.feature.receive
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCAction
|
||||||
|
import kotlinx.cinterop.ObjCObjectVar
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.memScoped
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
import kotlinx.cinterop.readBytes
|
import kotlinx.cinterop.readBytes
|
||||||
|
import kotlinx.cinterop.value
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusAuthorized
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusDenied
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusNotDetermined
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusRestricted
|
||||||
|
import platform.AVFoundation.AVCaptureDevice
|
||||||
|
import platform.AVFoundation.AVCaptureDeviceInput
|
||||||
|
import platform.AVFoundation.AVCaptureMetadataOutput
|
||||||
|
import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol
|
||||||
|
import platform.AVFoundation.AVCaptureOutput
|
||||||
|
import platform.AVFoundation.AVCaptureConnection
|
||||||
|
import platform.AVFoundation.AVCaptureSession
|
||||||
|
import platform.AVFoundation.AVCaptureSessionPresetHigh
|
||||||
|
import platform.AVFoundation.AVCaptureVideoPreviewLayer
|
||||||
|
import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill
|
||||||
|
import platform.AVFoundation.AVMediaTypeVideo
|
||||||
|
import platform.AVFoundation.AVMetadataMachineReadableCodeObject
|
||||||
|
import platform.AVFoundation.AVMetadataObjectTypeQRCode
|
||||||
|
import platform.AVFoundation.authorizationStatusForMediaType
|
||||||
|
import platform.AVFoundation.requestAccessForMediaType
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.CoreNFC.NFCNDEFMessage
|
||||||
|
import platform.CoreNFC.NFCNDEFPayload
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSession
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
||||||
|
import platform.CoreNFC.NFCTypeNameFormatMedia
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSError
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
import platform.Foundation.NSURL
|
import platform.Foundation.NSURL
|
||||||
|
import platform.UIKit.NSTextAlignmentCenter
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
|
import platform.UIKit.UIButton
|
||||||
|
import platform.UIKit.UIButtonTypeSystem
|
||||||
|
import platform.UIKit.UIColor
|
||||||
|
import platform.UIKit.UIControlEventTouchUpInside
|
||||||
|
import platform.UIKit.UIControlStateNormal
|
||||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
|
import platform.UIKit.UILabel
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
import platform.UIKit.UIModalPresentationFormSheet
|
||||||
|
import platform.UIKit.UIModalPresentationFullScreen
|
||||||
|
import platform.UIKit.UIViewAutoresizingFlexibleHeight
|
||||||
|
import platform.UIKit.UIViewAutoresizingFlexibleWidth
|
||||||
|
import platform.UIKit.UIViewController
|
||||||
import platform.UniformTypeIdentifiers.UTTypeData
|
import platform.UniformTypeIdentifiers.UTTypeData
|
||||||
|
import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT
|
||||||
import platform.darwin.NSObject
|
import platform.darwin.NSObject
|
||||||
|
import platform.darwin.dispatch_async
|
||||||
|
import platform.darwin.dispatch_get_global_queue
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
|
||||||
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
||||||
|
private var retainedQrScanner: QrScannerViewController? = null
|
||||||
|
private var retainedNfcReader: InvitationNfcReader? = null
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
||||||
object : ReceiveInvitationActions {
|
object : ReceiveInvitationActions {
|
||||||
override val fileAvailability = ReceiveMethodAvailability.Available
|
override val fileAvailability = ReceiveMethodAvailability.Available
|
||||||
// Hide unfinished iOS methods so the method list only shows what works.
|
override val qrAvailability =
|
||||||
override val qrAvailability = ReceiveMethodAvailability.Hidden
|
if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) {
|
||||||
override val nfcAvailability = ReceiveMethodAvailability.Hidden
|
ReceiveMethodAvailability.Available
|
||||||
|
} else {
|
||||||
|
ReceiveMethodAvailability.Unavailable
|
||||||
|
}
|
||||||
|
override val nfcAvailability =
|
||||||
|
if (NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
ReceiveMethodAvailability.Available
|
||||||
|
} else {
|
||||||
|
ReceiveMethodAvailability.Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
cancel()
|
||||||
|
val presenter = topPresenter()
|
||||||
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
|
||||||
val delegate = InvitationDocumentDelegate(onResult)
|
val delegate = InvitationDocumentDelegate(onResult)
|
||||||
@@ -35,13 +96,67 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb
|
|||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun scanQrCode(onResult: (Result<String>) -> Unit) =
|
override fun scanQrCode(onResult: (Result<String>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
|
cancel()
|
||||||
|
val presenter = topPresenter()
|
||||||
|
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
||||||
|
ensureCameraAccess { granted ->
|
||||||
|
if (!granted) {
|
||||||
|
onResult(Result.failure(IllegalStateException("Camera access is required to scan QR codes")))
|
||||||
|
return@ensureCameraAccess
|
||||||
|
}
|
||||||
|
val scanner = QrScannerViewController { result ->
|
||||||
|
retainedQrScanner = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedQrScanner = scanner
|
||||||
|
scanner.modalPresentationStyle = UIModalPresentationFullScreen
|
||||||
|
presenter.presentViewController(scanner, animated = true, completion = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) =
|
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build")))
|
cancel()
|
||||||
|
if (!NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
onResult(Result.failure(UnsupportedOperationException("NFC reading is unavailable on this device")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reader = InvitationNfcReader { result ->
|
||||||
|
retainedNfcReader = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedNfcReader = reader
|
||||||
|
reader.start()
|
||||||
|
}
|
||||||
|
|
||||||
override fun cancel() = Unit
|
override fun cancel() {
|
||||||
|
retainedNfcReader?.cancel()
|
||||||
|
retainedNfcReader = null
|
||||||
|
retainedQrScanner?.cancelScan()
|
||||||
|
retainedQrScanner = null
|
||||||
|
retainedInvitationDelegate = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun topPresenter(): UIViewController? {
|
||||||
|
var controller = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
|
while (controller?.presentedViewController != null) {
|
||||||
|
controller = controller?.presentedViewController
|
||||||
|
}
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCameraAccess(onResult: (Boolean) -> Unit) {
|
||||||
|
when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) {
|
||||||
|
AVAuthorizationStatusAuthorized -> onResult(true)
|
||||||
|
AVAuthorizationStatusNotDetermined -> {
|
||||||
|
AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted ->
|
||||||
|
dispatch_async(dispatch_get_main_queue()) { onResult(granted) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> onResult(false)
|
||||||
|
else -> onResult(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +165,9 @@ private class InvitationDocumentDelegate(
|
|||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||||
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
|
||||||
onResult(runCatching {
|
onResult(runCatching {
|
||||||
requireNotNull(url) { "The selected invitation URL was invalid" }
|
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
||||||
|
?: error("The selected invitation URL was invalid")
|
||||||
val path = url.path ?: error("The invitation path was invalid")
|
val path = url.path ?: error("The invitation path was invalid")
|
||||||
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
|
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
|
||||||
val length = data.length.toInt()
|
val length = data.length.toInt()
|
||||||
@@ -67,3 +182,203 @@ private class InvitationDocumentDelegate(
|
|||||||
retainedInvitationDelegate = null
|
retainedInvitationDelegate = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private class QrScannerViewController(
|
||||||
|
private val onResult: (Result<String>) -> Unit,
|
||||||
|
) : UIViewController(nibName = null, bundle = null), AVCaptureMetadataOutputObjectsDelegateProtocol {
|
||||||
|
private val session = AVCaptureSession()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer? = null
|
||||||
|
private var finished = false
|
||||||
|
private val closeTarget = ButtonTarget { cancelScan() }
|
||||||
|
|
||||||
|
override fun viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = UIColor.blackColor
|
||||||
|
|
||||||
|
val hint = UILabel(frame = view.bounds).apply {
|
||||||
|
text = "Point the camera at a VniDrop QR code"
|
||||||
|
textColor = UIColor.whiteColor
|
||||||
|
textAlignment = NSTextAlignmentCenter
|
||||||
|
numberOfLines = 0
|
||||||
|
autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight
|
||||||
|
}
|
||||||
|
view.addSubview(hint)
|
||||||
|
|
||||||
|
val close = UIButton.buttonWithType(UIButtonTypeSystem).apply {
|
||||||
|
setTitle("Cancel", forState = UIControlStateNormal)
|
||||||
|
setTitleColor(UIColor.whiteColor, forState = UIControlStateNormal)
|
||||||
|
addTarget(closeTarget, platform.objc.sel_registerName("invoke"), UIControlEventTouchUpInside)
|
||||||
|
setFrame(CGRectMake(16.0, 52.0, 88.0, 36.0))
|
||||||
|
}
|
||||||
|
view.addSubview(close)
|
||||||
|
configureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
previewLayer?.setFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewWillDisappear(animated: Boolean) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if (session.running) session.stopRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelScan() {
|
||||||
|
finish(Result.failure(IllegalStateException("QR scanning was cancelled")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureSession() {
|
||||||
|
val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
|
||||||
|
?: return finish(Result.failure(IllegalStateException("No camera is available")))
|
||||||
|
|
||||||
|
memScoped {
|
||||||
|
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
|
||||||
|
val input = AVCaptureDeviceInput.deviceInputWithDevice(device, errorPtr.ptr)
|
||||||
|
if (input == null) {
|
||||||
|
finish(
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(errorPtr.value?.localizedDescription ?: "Could not open the camera"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!session.canAddInput(input)) {
|
||||||
|
finish(Result.failure(IllegalStateException("Could not configure the camera input")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
val output = AVCaptureMetadataOutput()
|
||||||
|
if (!session.canAddOutput(output)) {
|
||||||
|
finish(Result.failure(IllegalStateException("Could not configure the QR scanner")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue())
|
||||||
|
output.metadataObjectTypes = listOf(AVMetadataObjectTypeQRCode)
|
||||||
|
|
||||||
|
val layer = AVCaptureVideoPreviewLayer(session = session).apply {
|
||||||
|
videoGravity = AVLayerVideoGravityResizeAspectFill
|
||||||
|
setFrame(view.bounds)
|
||||||
|
}
|
||||||
|
view.layer.insertSublayer(layer, atIndex = 0u)
|
||||||
|
previewLayer = layer
|
||||||
|
session.sessionPreset = AVCaptureSessionPresetHigh
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) {
|
||||||
|
session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureOutput(
|
||||||
|
output: AVCaptureOutput,
|
||||||
|
didOutputMetadataObjects: List<*>,
|
||||||
|
fromConnection: AVCaptureConnection,
|
||||||
|
) {
|
||||||
|
val code = didOutputMetadataObjects
|
||||||
|
.mapNotNull { it as? AVMetadataMachineReadableCodeObject }
|
||||||
|
.firstOrNull { it.type == AVMetadataObjectTypeQRCode }
|
||||||
|
val value = code?.stringValue?.trim().orEmpty()
|
||||||
|
if (value.isNotEmpty()) {
|
||||||
|
finish(Result.success(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
if (session.running) session.stopRunning()
|
||||||
|
if (presentingViewController != null) {
|
||||||
|
dismissViewControllerAnimated(true) { onResult(result) }
|
||||||
|
} else {
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(BetaInteropApi::class)
|
||||||
|
private class ButtonTarget(
|
||||||
|
private val onClick: () -> Unit,
|
||||||
|
) : NSObject() {
|
||||||
|
@ObjCAction
|
||||||
|
fun invoke() {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private class InvitationNfcReader(
|
||||||
|
private val onResult: (Result<String>) -> Unit,
|
||||||
|
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
||||||
|
private var session: NFCNDEFReaderSession? = null
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = true)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
||||||
|
session = reader
|
||||||
|
reader.beginSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
session?.invalidateSession()
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
||||||
|
if (finished) return
|
||||||
|
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
||||||
|
val cancelled = didInvalidateWithError.code == 200L
|
||||||
|
finish(
|
||||||
|
if (cancelled) {
|
||||||
|
Result.failure(IllegalStateException("NFC reading was cancelled"))
|
||||||
|
} else {
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(didInvalidateWithError.localizedDescription ?: "NFC reading failed"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
||||||
|
val ticket = runCatching {
|
||||||
|
val messages = didDetectNDEFs.mapNotNull { it as? NFCNDEFMessage }
|
||||||
|
messages
|
||||||
|
.flatMap { message -> message.records.mapNotNull { it as? NFCNDEFPayload } }
|
||||||
|
.firstNotNullOfOrNull(::payloadAsInvitation)
|
||||||
|
?: error("This NFC tag does not contain a VniDrop invitation")
|
||||||
|
}
|
||||||
|
session.invalidateSession()
|
||||||
|
finish(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
session = null
|
||||||
|
dispatch_async(dispatch_get_main_queue()) { onResult(result) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun payloadAsInvitation(payload: NFCNDEFPayload): String? {
|
||||||
|
val type = payload.type?.toByteArray()?.decodeToString() ?: return null
|
||||||
|
val data = payload.payload?.toByteArray() ?: return null
|
||||||
|
return when {
|
||||||
|
payload.typeNameFormat == NFCTypeNameFormatMedia && type == InvitationMimeType ->
|
||||||
|
decodeInvitationBytes(data)
|
||||||
|
payload.typeNameFormat == NFCTypeNameFormatMedia && type.startsWith("text/") ->
|
||||||
|
decodeInvitationBytes(data)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun NSData.toByteArray(): ByteArray {
|
||||||
|
val length = this.length.toInt()
|
||||||
|
if (length <= 0) return ByteArray(0)
|
||||||
|
return this.bytes?.readBytes(length) ?: ByteArray(0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,27 +2,47 @@ package com.vnidrop.app.feature.send
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import com.vnidrop.app.feature.receive.VniDropInvitationMimeType
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCSignatureOverride
|
||||||
|
import platform.CoreNFC.NFCNDEFMessage
|
||||||
|
import platform.CoreNFC.NFCNDEFPayload
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSession
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
||||||
|
import platform.CoreNFC.NFCNDEFStatusNotSupported
|
||||||
|
import platform.CoreNFC.NFCNDEFStatusReadOnly
|
||||||
|
import platform.CoreNFC.NFCNDEFTagProtocol
|
||||||
|
import platform.CoreNFC.NFCTypeNameFormatMedia
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSError
|
||||||
import platform.Foundation.NSString
|
import platform.Foundation.NSString
|
||||||
import platform.Foundation.NSTemporaryDirectory
|
import platform.Foundation.NSTemporaryDirectory
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.NSUTF8StringEncoding
|
import platform.Foundation.NSUTF8StringEncoding
|
||||||
|
import platform.Foundation.NSURL
|
||||||
import platform.Foundation.create
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataUsingEncoding
|
||||||
import platform.Foundation.writeToFile
|
import platform.Foundation.writeToFile
|
||||||
import platform.UIKit.UIActivityViewController
|
import platform.UIKit.UIActivityViewController
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
import platform.UIKit.UIModalPresentationFormSheet
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
|
||||||
|
private var retainedNfcWriter: InvitationNfcWriter? = null
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||||
object : TransferShareActions {
|
object : TransferShareActions {
|
||||||
override val canUseNativeShare = true
|
override val canUseNativeShare = true
|
||||||
// Core NFC tag writing requires the NFC entitlement. Keep the action
|
override val nfcAvailability =
|
||||||
// visible but disabled until that capability is provisioned for the app.
|
if (NFCNDEFReaderSession.readingAvailable) {
|
||||||
override val nfcAvailability = NfcShareAvailability.Unavailable
|
NfcShareAvailability.Available
|
||||||
|
} else {
|
||||||
|
NfcShareAvailability.Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
onResult(runCatching {
|
onResult(runCatching {
|
||||||
@@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build")))
|
cancelNfcWrite()
|
||||||
|
if (!NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on this device")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val writer = InvitationNfcWriter(ticket) { result ->
|
||||||
|
retainedNfcWriter = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedNfcWriter = writer
|
||||||
|
writer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelNfcWrite() {
|
||||||
|
retainedNfcWriter?.cancel()
|
||||||
|
retainedNfcWriter = null
|
||||||
}
|
}
|
||||||
override fun cancelNfcWrite() = Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,3 +98,106 @@ private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewCon
|
|||||||
private fun present(controller: platform.UIKit.UIViewController) {
|
private fun present(controller: platform.UIKit.UIViewController) {
|
||||||
presenter().presentViewController(controller, animated = true, completion = null)
|
presenter().presentViewController(controller, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private class InvitationNfcWriter(
|
||||||
|
private val ticket: String,
|
||||||
|
private val onResult: (Result<Unit>) -> Unit,
|
||||||
|
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
||||||
|
private var session: NFCNDEFReaderSession? = null
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = false)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
||||||
|
session = reader
|
||||||
|
reader.beginSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
session?.invalidateSession()
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
||||||
|
if (finished) return
|
||||||
|
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
||||||
|
val cancelled = didInvalidateWithError.code == 200L
|
||||||
|
finish(
|
||||||
|
if (cancelled) {
|
||||||
|
Result.failure(IllegalStateException("NFC writing was cancelled"))
|
||||||
|
} else {
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(didInvalidateWithError.localizedDescription),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
||||||
|
// Prefer tag-based write path via didDetectTags when available.
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectTags: List<*>) {
|
||||||
|
val tag = didDetectTags.firstOrNull() as? NFCNDEFTagProtocol
|
||||||
|
?: return finish(Result.failure(IllegalStateException("No NFC tag was detected")))
|
||||||
|
|
||||||
|
session.connectToTag(tag) { connectError ->
|
||||||
|
if (connectError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(connectError.localizedDescription)))
|
||||||
|
return@connectToTag
|
||||||
|
}
|
||||||
|
tag.queryNDEFStatusWithCompletionHandler { status, _, queryError ->
|
||||||
|
if (queryError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(queryError.localizedDescription)))
|
||||||
|
return@queryNDEFStatusWithCompletionHandler
|
||||||
|
}
|
||||||
|
when (status) {
|
||||||
|
NFCNDEFStatusNotSupported -> {
|
||||||
|
finish(Result.failure(IllegalStateException("This NFC tag does not support NDEF")))
|
||||||
|
}
|
||||||
|
NFCNDEFStatusReadOnly -> {
|
||||||
|
finish(Result.failure(IllegalStateException("This NFC tag is read-only")))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val message = invitationNdefMessage(ticket)
|
||||||
|
?: return@queryNDEFStatusWithCompletionHandler finish(
|
||||||
|
Result.failure(IllegalStateException("Could not encode the invitation for NFC")),
|
||||||
|
)
|
||||||
|
tag.writeNDEF(message) { writeError ->
|
||||||
|
if (writeError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(writeError.localizedDescription)))
|
||||||
|
} else {
|
||||||
|
session.alertMessage = "Invitation written"
|
||||||
|
session.invalidateSession()
|
||||||
|
finish(Result.success(Unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<Unit>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
session = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private fun invitationNdefMessage(ticket: String): NFCNDEFMessage? {
|
||||||
|
val type = NSString.create(string = VniDropInvitationMimeType).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
||||||
|
val payload = NSString.create(string = ticket).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
||||||
|
val record = NFCNDEFPayload(
|
||||||
|
format = NFCTypeNameFormatMedia,
|
||||||
|
type = type,
|
||||||
|
identifier = NSData(),
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
return NFCNDEFMessage(nDEFRecords = listOf(record))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user