diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3799332..02dd757 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -303,6 +304,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; 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_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -318,6 +321,10 @@ SystemConfiguration, "-framework", Network, + "-framework", + CoreNFC, + "-framework", + AVFoundation, ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -331,6 +338,7 @@ ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; @@ -338,6 +346,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; 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_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -353,6 +363,10 @@ SystemConfiguration, "-framework", Network, + "-framework", + CoreNFC, + "-framework", + AVFoundation, ); SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 1bc96a2..5592df5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -43,6 +43,10 @@ LSSupportsOpeningDocumentsInPlace + NSCameraUsageDescription + VniDrop uses the camera to scan transfer QR codes. + NFCReaderUsageDescription + VniDrop uses NFC to read transfer invitation tags. UIViewControllerBasedStatusBarAppearance diff --git a/iosApp/iosApp/vnidrop.entitlements b/iosApp/iosApp/vnidrop.entitlements new file mode 100644 index 0000000..5f7b942 --- /dev/null +++ b/iosApp/iosApp/vnidrop.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.nfc.readersession.formats + + NDEF + + + diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt index bf6f66f..c435555 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -2,30 +2,91 @@ package com.vnidrop.app.feature.receive import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import kotlinx.cinterop.BetaInteropApi 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.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.NSURL +import platform.UIKit.NSTextAlignmentCenter 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.UIDocumentPickerViewController +import platform.UIKit.UILabel 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.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT 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 retainedQrScanner: QrScannerViewController? = null +private var retainedNfcReader: InvitationNfcReader? = null @Composable actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember { object : ReceiveInvitationActions { override val fileAvailability = ReceiveMethodAvailability.Available - // Hide unfinished iOS methods so the method list only shows what works. - override val qrAvailability = ReceiveMethodAvailability.Hidden - override val nfcAvailability = ReceiveMethodAvailability.Hidden + override val qrAvailability = + if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) { + ReceiveMethodAvailability.Available + } else { + ReceiveMethodAvailability.Unavailable + } + override val nfcAvailability = + if (NFCNDEFReaderSession.readingAvailable) { + ReceiveMethodAvailability.Available + } else { + ReceiveMethodAvailability.Unavailable + } @OptIn(ExperimentalForeignApi::class) override fun pickInvitation(onResult: (Result) -> Unit) { - val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + cancel() + val presenter = topPresenter() ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true) val delegate = InvitationDocumentDelegate(onResult) @@ -35,13 +96,67 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb presenter.presentViewController(picker, animated = true, completion = null) } - override fun scanQrCode(onResult: (Result) -> Unit) = - onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build"))) + override fun scanQrCode(onResult: (Result) -> Unit) { + 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) -> Unit) = - onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build"))) + override fun readNfcInvitation(onResult: (Result) -> Unit) { + 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 { @OptIn(ExperimentalForeignApi::class) override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { - val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL 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 data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened") val length = data.length.toInt() @@ -67,3 +182,203 @@ private class InvitationDocumentDelegate( retainedInvitationDelegate = null } } + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class QrScannerViewController( + private val onResult: (Result) -> 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>() + 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) { + 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) -> 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) { + 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) +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt index ca9be15..130e3d3 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt @@ -2,27 +2,47 @@ package com.vnidrop.app.feature.send import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import kotlinx.cinterop.ExperimentalForeignApi +import com.vnidrop.app.feature.receive.VniDropInvitationMimeType 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.NSTemporaryDirectory -import platform.Foundation.NSURL import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.NSURL import platform.Foundation.create +import platform.Foundation.dataUsingEncoding import platform.Foundation.writeToFile import platform.UIKit.UIActivityViewController import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UIModalPresentationFormSheet +import platform.darwin.NSObject +import platform.darwin.dispatch_get_main_queue + +private var retainedNfcWriter: InvitationNfcWriter? = null @OptIn(ExperimentalForeignApi::class) @Composable actual fun rememberTransferShareActions(): TransferShareActions = remember { object : TransferShareActions { override val canUseNativeShare = true - // Core NFC tag writing requires the NFC entitlement. Keep the action - // visible but disabled until that capability is provisioned for the app. - override val nfcAvailability = NfcShareAvailability.Unavailable + override val nfcAvailability = + if (NFCNDEFReaderSession.readingAvailable) { + NfcShareAvailability.Available + } else { + NfcShareAvailability.Unavailable + } override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { onResult(runCatching { @@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember { } override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> 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) { presenter().presentViewController(controller, animated = true, completion = null) } + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class InvitationNfcWriter( + private val ticket: String, + private val onResult: (Result) -> 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) { + 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)) +}