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:
2026-07-12 17:57:00 +02:00
parent ed9d9ce8c5
commit d2929fbfbb
5 changed files with 498 additions and 18 deletions

View File

@@ -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;

View File

@@ -43,6 +43,10 @@
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<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>
<true/>
</dict>

View 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>

View File

@@ -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<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
override fun scanQrCode(onResult: (Result<String>) -> 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<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build")))
override fun readNfcInvitation(onResult: (Result<String>) -> 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<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)
}

View File

@@ -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>) -> Unit) {
onResult(runCatching {
@@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
}
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) {
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))
}