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;
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
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.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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user