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))
+}