mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
refactor(platform): remove Apple targets from KMP
This commit is contained in:
@@ -7,9 +7,10 @@ still applies; this file wins for UI/KMP work.
|
||||
|
||||
## Purpose
|
||||
|
||||
`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and
|
||||
`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work
|
||||
goes through UniFFI `VnidropCore` (see `crates/vnidrop`).
|
||||
`shared` is the Compose Multiplatform app layer for Android, Windows, and Linux:
|
||||
Compose UI, feature ViewModels, and `expect`/`actual` platform bridges. Native
|
||||
transfer work goes through UniFFI `VnidropCore` (see `crates/vnidrop`). Apple
|
||||
platforms use the native SwiftUI app under `apple/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +34,7 @@ lists, animation, accessibility:
|
||||
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
||||
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
||||
| Platform | `androidMain` / `iosMain` / `jvmMain` for pickers, SAF, security-scoped URLs, NFC/QR. |
|
||||
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
|
||||
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
||||
|
||||
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
|
||||
@@ -53,14 +54,12 @@ Optional:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:testAndroidHostTest
|
||||
./gradlew :shared:iosSimulatorArm64Test
|
||||
./gradlew :desktopApp:run
|
||||
./gradlew :androidApp:assembleDebug
|
||||
```
|
||||
|
||||
CI `:shared:jvmTest` currently runs on **macOS**. Gobley host cargo follows the
|
||||
current host and architecture, including Linux and Windows; use macOS only when
|
||||
exact CI parity is required.
|
||||
CI `:shared:jvmTest` runs on **Linux**. Gobley host cargo follows the current
|
||||
host and architecture so local desktop builds embed their matching Rust library.
|
||||
|
||||
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
||||
`jvmTest` so Gobley/native pieces stay aligned.
|
||||
@@ -76,7 +75,7 @@ src/
|
||||
feature/send|receive|approvals|settings|app/
|
||||
ui/ # theme, components, navigation, feedback, state helpers
|
||||
commonMain/composeResources/
|
||||
androidMain|iosMain|jvmMain/
|
||||
androidMain|jvmMain/
|
||||
commonTest|jvmTest|...
|
||||
```
|
||||
|
||||
@@ -86,7 +85,8 @@ src/
|
||||
documents with relative `displayName` paths before calling Rust
|
||||
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
||||
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
|
||||
- **iOS:** keep security-scoped leases alive while Rust reads paths.
|
||||
- **Apple:** lives outside this module under `apple/`; do not add Apple platform
|
||||
behavior back to KMP.
|
||||
- **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk.
|
||||
|
||||
Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||
|
||||
import gobley.gradle.cargo.dsl.appleMobile
|
||||
import gobley.gradle.cargo.dsl.jvm
|
||||
import gobley.gradle.cargo.tasks.CargoBuildTask
|
||||
import gobley.gradle.cargo.tasks.CargoCheckTask
|
||||
import gobley.gradle.GobleyHost
|
||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
||||
import gobley.gradle.rust.targets.RustTarget
|
||||
import gobley.gradle.Variant
|
||||
import org.gradle.api.DefaultTask
|
||||
@@ -113,18 +111,6 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Shared"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidTarget {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
@@ -190,9 +176,6 @@ android {
|
||||
val hostCargoTargets = buildSet<RustTarget> {
|
||||
add(GobleyHost.current.rustTarget)
|
||||
addAll(RustAndroidTarget.entries)
|
||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||
addAll(RustAppleMobileTarget.entries)
|
||||
}
|
||||
}
|
||||
|
||||
cargo {
|
||||
@@ -207,15 +190,6 @@ cargo {
|
||||
embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget)
|
||||
}
|
||||
}
|
||||
builds.appleMobile {
|
||||
variants {
|
||||
buildTaskProvider.configure {
|
||||
if (rustTarget.cinteropName == "ios") {
|
||||
additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
builds.configureEach {
|
||||
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
||||
installTargetBeforeBuild.set(buildOnCurrentHost)
|
||||
|
||||
@@ -52,15 +52,13 @@ private class AndroidFileSystemService(
|
||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
||||
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||
ReceiveFolderKind.FileSystemPath,
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
||||
ReceiveFolderKind.FileSystemPath -> null
|
||||
}
|
||||
|
||||
override suspend fun sharePickedFiles(
|
||||
|
||||
@@ -134,13 +134,6 @@ interface CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
suspend fun shareSecurityScopedFileUrl(
|
||||
fileUrl: String,
|
||||
displayName: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
/** Multi-source share used by multi-file pickers. */
|
||||
suspend fun shareSources(
|
||||
sources: List<uniffi.vnidrop.ShareSource>,
|
||||
@@ -151,7 +144,6 @@ interface CoreGateway {
|
||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||
suspend fun delete(transferId: ULong): Result<Unit>
|
||||
suspend fun clearReceiveHistory(): Result<ULong>
|
||||
|
||||
@@ -113,27 +113,6 @@ class CoreRepository(
|
||||
accessPolicy = accessPolicy,
|
||||
)
|
||||
|
||||
override suspend fun shareSecurityScopedFileUrl(
|
||||
fileUrl: String,
|
||||
displayName: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> =
|
||||
shareSources(
|
||||
sources = listOf(
|
||||
ShareSource(
|
||||
kind = SourceKind.IOS_SECURITY_SCOPED_URL,
|
||||
value = fileUrl,
|
||||
displayName = displayName.ifBlank { fileUrl.substringAfterLast('/').ifBlank { "transfer" } },
|
||||
isDirectory = false,
|
||||
),
|
||||
),
|
||||
transferName = transferName,
|
||||
senderName = senderName,
|
||||
accessPolicy = accessPolicy,
|
||||
)
|
||||
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||
_state.update { it.copy(lastInspection = inspection) }
|
||||
@@ -154,17 +133,6 @@ class CoreRepository(
|
||||
refreshSnapshot()
|
||||
}
|
||||
|
||||
override suspend fun receiveIntoSecurityScopedDirectory(
|
||||
ticket: String,
|
||||
outputDirectoryUrl: String,
|
||||
receiverName: String,
|
||||
): Result<Unit> = runCore {
|
||||
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
|
||||
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
||||
}
|
||||
refreshSnapshot()
|
||||
}
|
||||
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().cancelTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
|
||||
@@ -10,9 +10,9 @@ data class PickedShareFile(
|
||||
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
||||
val isTemporaryCopy: Boolean = false,
|
||||
/**
|
||||
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
||||
* folder URL, or Android document tree URI). Platform share code expands or
|
||||
* walks it; Rust cannot treat an Android FD as a directory.
|
||||
* When true, [value] is a directory (filesystem path or Android document tree
|
||||
* URI). Platform share code expands or walks it; Rust cannot treat an Android
|
||||
* FD as a directory.
|
||||
*/
|
||||
val isDirectory: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ enum class ReceiveFolderKind {
|
||||
/** Shared system Downloads via MediaStore (Android 10+). */
|
||||
AndroidPublicDownloads,
|
||||
AndroidTreeUri,
|
||||
IosSecurityScopedUrl,
|
||||
}
|
||||
|
||||
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
||||
|
||||
@@ -2,10 +2,8 @@ package com.vnidrop.app.core
|
||||
|
||||
import uniffi.vnidrop.SourceKind
|
||||
|
||||
// Platform file handles have different lifetime rules. Desktop paths need no
|
||||
// extra work, Rust duplicates Android fd sources immediately, and iOS
|
||||
// security-scoped URLs must remain leased while Rust performs the blocking
|
||||
// import/export call.
|
||||
// Desktop paths need no extra work, while Rust duplicates borrowed Android file
|
||||
// descriptors immediately before the platform closes them.
|
||||
internal expect suspend fun <T> withPlatformPathAccess(
|
||||
kind: SourceKind,
|
||||
value: String,
|
||||
|
||||
@@ -161,14 +161,10 @@ class ReceiveViewModel(
|
||||
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
||||
}
|
||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||
val result = when {
|
||||
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
|
||||
current.ticket,
|
||||
folder.value,
|
||||
current.receiverName,
|
||||
)
|
||||
else -> repository.receive(current.ticket, folder.value, current.receiverName)
|
||||
val result = if (outputSink != null) {
|
||||
repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||
} else {
|
||||
repository.receive(current.ticket, folder.value, current.receiverName)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
|
||||
@@ -217,7 +217,7 @@ class ViewModelsTest {
|
||||
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
|
||||
val externalFolder = ReceiveFolder(ReceiveFolderKind.IosSecurityScopedUrl, "file:///external", "External")
|
||||
val externalFolder = ReceiveFolder(ReceiveFolderKind.AndroidTreeUri, "content://external", "External")
|
||||
val preferences = preferences().apply {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
||||
}
|
||||
|
||||
@@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun shareSecurityScopedFileUrl(
|
||||
fileUrl: String,
|
||||
displayName: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun shareSources(
|
||||
sources: List<uniffi.vnidrop.ShareSource>,
|
||||
transferName: String,
|
||||
@@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway {
|
||||
awaitReceiveIfNeeded()
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> {
|
||||
receiveCount += 1
|
||||
lastReceiveTicket = ticket
|
||||
lastReceiveReceiverName = receiverName
|
||||
awaitReceiveIfNeeded()
|
||||
return receiveResult
|
||||
}
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> {
|
||||
cancelledTransfers += transferId
|
||||
return Result.success(Unit)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||
|
||||
fun MainViewController(externalInvitations: ExternalInvitationController) =
|
||||
ComposeUIViewController { App(rememberIosAppDependencies(externalInvitations)) }
|
||||
@@ -1,57 +0,0 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vnidrop.app.core.rememberFileSystemService
|
||||
import com.vnidrop.app.notifications.IosLocalNotificationService
|
||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||
import platform.Foundation.NSBundle
|
||||
import platform.Foundation.NSApplicationSupportDirectory
|
||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
import platform.UIKit.UIDevice
|
||||
|
||||
@Composable
|
||||
fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
|
||||
val fileSystemService = rememberFileSystemService()
|
||||
return remember(fileSystemService) {
|
||||
val device = UIDevice.currentDevice
|
||||
AppDependencies(
|
||||
environment = PlatformEnvironment(
|
||||
name = device.systemName() + " " + device.systemVersion,
|
||||
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
|
||||
defaultCoreDataDir = iosApplicationDataDirectory(),
|
||||
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
|
||||
),
|
||||
deviceInfoProvider = IosDeviceInfoProvider(device),
|
||||
fileSystemService = fileSystemService,
|
||||
localNotificationService = IosLocalNotificationService(),
|
||||
externalInvitations = externalInvitations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun iosApplicationDataDirectory(): String =
|
||||
(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String)
|
||||
?.trimEnd('/')?.plus("/VniDrop")
|
||||
?: error("iOS Application Support directory is unavailable")
|
||||
|
||||
private class IosDeviceInfoProvider(
|
||||
private val device: UIDevice,
|
||||
) : DeviceInfoProvider {
|
||||
override suspend fun load(): DeviceInfo = DeviceInfo(
|
||||
deviceName = device.name,
|
||||
deviceModel = device.model,
|
||||
operatingSystem = device.systemName() + " " + device.systemVersion,
|
||||
network = null,
|
||||
batteryLevel = runCatching {
|
||||
val wasMonitoring = device.batteryMonitoringEnabled
|
||||
try {
|
||||
device.batteryMonitoringEnabled = true
|
||||
device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" }
|
||||
} finally {
|
||||
device.batteryMonitoringEnabled = wasMonitoring
|
||||
}
|
||||
}.getOrNull(),
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.readBytes
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.UIKit.UIApplication
|
||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||
import platform.UIKit.UIDocumentPickerViewController
|
||||
import platform.UIKit.UIDocumentInteractionController
|
||||
import platform.UIKit.UIImage
|
||||
import platform.UIKit.UIImagePNGRepresentation
|
||||
import platform.UIKit.UIModalPresentationFormSheet
|
||||
import platform.UniformTypeIdentifiers.UTTypeFolder
|
||||
import platform.UniformTypeIdentifiers.UTTypeItem
|
||||
import platform.darwin.NSObject
|
||||
|
||||
private var retainedPickerDelegate: DocumentPickerDelegate? = null
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareFilePicker(
|
||||
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ShareFilePicker = remember(onFilesPicked, onError) {
|
||||
object : ShareFilePicker {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
override fun pickFiles() {
|
||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
if (presenter == null) {
|
||||
onError("Could not find an iOS view controller for the document picker")
|
||||
return
|
||||
}
|
||||
|
||||
// The composer outlives this callback, so Rust imports a sandbox copy instead of a short-lived provider URL.
|
||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true)
|
||||
picker.allowsMultipleSelection = true
|
||||
val delegate = DocumentPickerDelegate(
|
||||
onFilesPicked = onFilesPicked,
|
||||
onError = onError,
|
||||
useFileSystemPaths = true,
|
||||
)
|
||||
retainedPickerDelegate = delegate
|
||||
picker.delegate = delegate
|
||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
override fun pickFolder() {
|
||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
if (presenter == null) {
|
||||
onError("Could not find an iOS view controller for the folder picker")
|
||||
return
|
||||
}
|
||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true)
|
||||
val delegate = DocumentPickerDelegate(
|
||||
onFilesPicked = { folders ->
|
||||
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
||||
onFilesPicked(
|
||||
listOf(
|
||||
folder.copy(isDirectory = true),
|
||||
),
|
||||
)
|
||||
},
|
||||
onError = onError,
|
||||
forceDirectory = true,
|
||||
useFileSystemPaths = true,
|
||||
)
|
||||
retainedPickerDelegate = delegate
|
||||
picker.delegate = delegate
|
||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberReceiveFolderPicker(
|
||||
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
|
||||
object : ReceiveFolderPicker {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
override fun pickFolder() {
|
||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
if (presenter == null) {
|
||||
onError("Could not find an iOS view controller for the folder picker")
|
||||
return
|
||||
}
|
||||
|
||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
||||
val delegate = DocumentPickerDelegate(
|
||||
onFilesPicked = { folders ->
|
||||
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
||||
onFolderPicked(
|
||||
ReceiveFolder(
|
||||
kind = ReceiveFolderKind.IosSecurityScopedUrl,
|
||||
value = folder.value,
|
||||
displayName = folder.displayName,
|
||||
),
|
||||
)
|
||||
},
|
||||
onError = onError,
|
||||
)
|
||||
retainedPickerDelegate = delegate
|
||||
picker.delegate = delegate
|
||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DocumentPickerDelegate(
|
||||
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||
private val onError: (String) -> Unit,
|
||||
private val forceDirectory: Boolean = false,
|
||||
private val useFileSystemPaths: Boolean = false,
|
||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
||||
val url = raw as? NSURL ?: return@mapNotNull null
|
||||
val displayName = url.lastPathComponent ?: "transfer"
|
||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||
val sizeBytes = try {
|
||||
if (forceDirectory) {
|
||||
null
|
||||
} else {
|
||||
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
||||
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
||||
}
|
||||
} finally {
|
||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
PickedShareFile(
|
||||
if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(),
|
||||
displayName,
|
||||
sizeBytes,
|
||||
nativeFileIcon(url),
|
||||
isTemporaryCopy = useFileSystemPaths,
|
||||
isDirectory = forceDirectory,
|
||||
)
|
||||
}
|
||||
if (files.isEmpty()) {
|
||||
onError("The selected iOS document URL was invalid")
|
||||
} else {
|
||||
onFilesPicked(files)
|
||||
}
|
||||
retainedPickerDelegate = null
|
||||
}
|
||||
|
||||
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
|
||||
retainedPickerDelegate = null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching {
|
||||
val controller = UIDocumentInteractionController.interactionControllerWithURL(url)
|
||||
val icon = controller.icons.lastOrNull() as? UIImage ?: return null
|
||||
val data = UIImagePNGRepresentation(icon) ?: return null
|
||||
data.bytes?.readBytes(data.length.toInt())
|
||||
}.getOrNull()
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
import platform.UIKit.UIApplication
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import uniffi.vnidrop.SourceKind
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileSystemService(): FileSystemService =
|
||||
remember { IosFileSystemService() }
|
||||
|
||||
private class IosFileSystemService : FileSystemService {
|
||||
// App-owned Documents remains durable across launches; raw external picker URLs do not.
|
||||
override val supportsCustomReceiveFolders: Boolean = false
|
||||
|
||||
override fun defaultReceiveFolder(): ReceiveFolder {
|
||||
val path = NSSearchPathForDirectoriesInDomains(
|
||||
NSDocumentDirectory,
|
||||
NSUserDomainMask,
|
||||
true,
|
||||
).firstOrNull() as? String ?: ""
|
||||
return ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
value = path,
|
||||
displayName = "Documents",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.FileSystemPath -> {
|
||||
if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) {
|
||||
FolderAccessStatus.Writable
|
||||
} else {
|
||||
FolderAccessStatus.Unavailable
|
||||
}
|
||||
}
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
|
||||
ReceiveFolderKind.AndroidTreeUri,
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
||||
|
||||
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
|
||||
files.asSequence()
|
||||
.filter(PickedShareFile::isTemporaryCopy)
|
||||
.map(PickedShareFile::value)
|
||||
.distinct()
|
||||
.forEach { path -> NSFileManager.defaultManager.removeItemAtPath(path, null) }
|
||||
}
|
||||
|
||||
override fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean =
|
||||
folder.kind == ReceiveFolderKind.FileSystemPath &&
|
||||
folder.value.trimEnd('/') == defaultReceiveFolder().value.trimEnd('/')
|
||||
|
||||
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
|
||||
if (!canRevealReceiveFolder(folder)) {
|
||||
return Result.failure(IllegalArgumentException("The receive folder is not VniDrop Documents"))
|
||||
}
|
||||
// Files can reveal app-owned Documents after the sharing keys in Info.plist are enabled.
|
||||
val url = NSURL.URLWithString("shareddocuments://${folder.value}")
|
||||
?: return Result.failure(IllegalStateException("The Files location URL is unavailable"))
|
||||
val opened = suspendCancellableCoroutine { continuation ->
|
||||
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
|
||||
if (continuation.isActive) continuation.resume(success)
|
||||
}
|
||||
}
|
||||
return if (opened) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Could not open VniDrop Documents in Files"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sharePickedFiles(
|
||||
repository: CoreGateway,
|
||||
files: List<PickedShareFile>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> {
|
||||
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||
return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy)
|
||||
}
|
||||
|
||||
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
|
||||
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||
return try {
|
||||
val path = url.path
|
||||
if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) {
|
||||
FolderAccessStatus.Writable
|
||||
} else {
|
||||
FolderAccessStatus.PermissionRequired
|
||||
}
|
||||
} finally {
|
||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PickedShareFile.toIosShareSource() = uniffi.vnidrop.ShareSource(
|
||||
kind = SourceKind.PATH,
|
||||
value = value,
|
||||
displayName = displayName,
|
||||
isDirectory = isDirectory,
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import platform.Foundation.NSURL
|
||||
import uniffi.vnidrop.SourceKind
|
||||
|
||||
internal actual suspend fun <T> withPlatformPathAccess(
|
||||
kind: SourceKind,
|
||||
value: String,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
if (kind != SourceKind.IOS_SECURITY_SCOPED_URL) {
|
||||
return block()
|
||||
}
|
||||
|
||||
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
if (didStartAccess) {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.writeToFile
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
IosPendingCrashStore(appDataDir)
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private class IosPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val fileManager = NSFileManager.defaultManager
|
||||
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
|
||||
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
ensureDirectory()
|
||||
val path = "$directory/${report.id}.crash"
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
val data = payload.encodeToByteArray().toNSData()
|
||||
data.writeToFile(path, atomically = true)
|
||||
}
|
||||
|
||||
override fun list(): List<CrashReport> {
|
||||
ensureDirectory()
|
||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.endsWith(".crash") }
|
||||
return names.mapNotNull { name ->
|
||||
val path = "$directory/$name"
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
|
||||
val text = data.toUtf8String()
|
||||
CrashReportCodec.decode(text)
|
||||
}.sortedByDescending { it.timestampMillis }
|
||||
}
|
||||
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
fileManager.removeItemAtPath("$directory/$id.crash", null)
|
||||
}
|
||||
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
ensureDirectory()
|
||||
val reports = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.endsWith(".crash") }
|
||||
.mapNotNull { name ->
|
||||
val path = "$directory/$name"
|
||||
val report = NSData.dataWithContentsOfFile(path)
|
||||
?.toUtf8String()
|
||||
?.let(CrashReportCodec::decode)
|
||||
if (report == null) {
|
||||
fileManager.removeItemAtPath(path, null)
|
||||
null
|
||||
} else {
|
||||
name to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
reports.forEachIndexed { index, (name, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) {
|
||||
fileManager.removeItemAtPath("$directory/$name", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun ByteArray.toNSData(): NSData =
|
||||
usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toUtf8String(): String {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ""
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ""
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result.decodeToString()
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlin.experimental.ExperimentalNativeApi
|
||||
|
||||
@OptIn(ExperimentalNativeApi::class)
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = setUnhandledExceptionHook { throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
// Terminate like the default hook after capture.
|
||||
terminateWithUnhandledException(throwable)
|
||||
}
|
||||
// Keep a reference so the previous hook is not GC'd unused; we intentionally
|
||||
// replace the default with capture-then-terminate.
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val ignored = previous
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSHTTPURLResponse
|
||||
import platform.Foundation.NSMutableURLRequest
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSURLSession
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataTaskWithRequest
|
||||
import platform.Foundation.setHTTPBody
|
||||
import platform.Foundation.setHTTPMethod
|
||||
import platform.Foundation.setValue
|
||||
import platform.posix.memcpy
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun platformHttpPost(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
bodyUtf8: String,
|
||||
): PlatformHttpResponse = suspendCancellableCoroutine { cont ->
|
||||
val nsUrl = NSURL.URLWithString(url)
|
||||
if (nsUrl == null) {
|
||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = "invalid_url"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val request = NSMutableURLRequest.requestWithURL(nsUrl).apply {
|
||||
setHTTPMethod("POST")
|
||||
setValue("application/json; charset=utf-8", forHTTPHeaderField = "Content-Type")
|
||||
headers.forEach { (key, value) ->
|
||||
setValue(value, forHTTPHeaderField = key)
|
||||
}
|
||||
setHTTPBody(bodyUtf8.encodeToByteArray().toNSData())
|
||||
}
|
||||
val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error ->
|
||||
if (!cont.isActive) return@dataTaskWithRequest
|
||||
if (error != null) {
|
||||
val message = error.localizedDescription
|
||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = message))
|
||||
return@dataTaskWithRequest
|
||||
}
|
||||
val http = response as? NSHTTPURLResponse
|
||||
val status = http?.statusCode?.toInt() ?: 0
|
||||
val body = data?.toUtf8String().orEmpty()
|
||||
cont.resume(PlatformHttpResponse(statusCode = status, body = body))
|
||||
}
|
||||
cont.invokeOnCancellation { task.cancel() }
|
||||
task.resume()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun ByteArray.toNSData(): NSData =
|
||||
usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toUtf8String(): String {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ""
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ""
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result.decodeToString()
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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
|
||||
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) {
|
||||
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)
|
||||
retainedInvitationDelegate = delegate
|
||||
picker.delegate = delegate
|
||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
|
||||
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) {
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private class InvitationDocumentDelegate(
|
||||
private val onResult: (Result<String>) -> Unit,
|
||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||
onResult(runCatching {
|
||||
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()
|
||||
require(length <= MaxInvitationBytes) { "The invitation is too large" }
|
||||
val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty")
|
||||
decodeInvitationBytes(bytes)
|
||||
})
|
||||
retainedInvitationDelegate = null
|
||||
}
|
||||
|
||||
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
|
||||
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)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.readBytes
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileModificationDate
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.Foundation.writeToFile
|
||||
|
||||
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore =
|
||||
IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews")
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private class IosPreviewStore(private val directory: String) : PlatformPreviewStore {
|
||||
private val files = NSFileManager.defaultManager
|
||||
|
||||
override fun list(): List<PreviewFileInfo> {
|
||||
ensureDirectory()
|
||||
return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance<String>().mapNotNull { name ->
|
||||
val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null
|
||||
val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null
|
||||
val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
|
||||
val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0
|
||||
PreviewFileInfo(id, size, modified.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(transferId: ULong): ByteArray? {
|
||||
val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null
|
||||
return data.bytes?.readBytes(data.length.toInt())
|
||||
}
|
||||
|
||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
|
||||
ensureDirectory()
|
||||
if (files.fileExistsAtPath(path(transferId))) return true
|
||||
val temporary = "$directory/.$transferId.tmp"
|
||||
val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) }
|
||||
if (!data.writeToFile(temporary, atomically = true)) return false
|
||||
val moved = files.moveItemAtPath(temporary, path(transferId), null)
|
||||
if (!moved) files.removeItemAtPath(temporary, null)
|
||||
return moved
|
||||
}
|
||||
|
||||
override fun delete(transferId: ULong) {
|
||||
files.removeItemAtPath(path(transferId), null)
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
|
||||
private fun path(transferId: ULong) = "$directory/$transferId.preview"
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.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
|
||||
override val nfcAvailability =
|
||||
if (NFCNDEFReaderSession.readingAvailable) {
|
||||
NfcShareAvailability.Available
|
||||
} else {
|
||||
NfcShareAvailability.Unavailable
|
||||
}
|
||||
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(runCatching {
|
||||
val url = createInvitation(ticket, transferName)
|
||||
val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true)
|
||||
present(picker)
|
||||
})
|
||||
}
|
||||
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(runCatching {
|
||||
val url = createInvitation(ticket, transferName)
|
||||
val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null)
|
||||
controller.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter().presentViewController(controller, animated = true, completion = null)
|
||||
})
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun createInvitation(ticket: String, transferName: String): NSURL {
|
||||
val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName)
|
||||
val text = NSString.create(string = ticket)
|
||||
require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) {
|
||||
"The invitation file could not be created"
|
||||
}
|
||||
return NSURL.fileURLWithPath(path)
|
||||
}
|
||||
|
||||
private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
?: error("Could not find an iOS view controller")
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileModificationDate
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fwrite
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||
IosPlatformLogStore(appDataDir, policy)
|
||||
|
||||
actual fun platformNowMillis(): Long =
|
||||
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private class IosPlatformLogStore(
|
||||
appDataDir: String,
|
||||
private val policy: LogRotationPolicy,
|
||||
) : PlatformLogStore {
|
||||
private val fileManager = NSFileManager.defaultManager
|
||||
private val directory = appDataDir.trimEnd('/') + "/logs"
|
||||
private val activePath = "$directory/app.log"
|
||||
|
||||
override val logDirectory: String = directory
|
||||
|
||||
override fun append(line: String) {
|
||||
ensureDirectory()
|
||||
val bytes = line.encodeToByteArray()
|
||||
if (policy.shouldRotate(fileSize(activePath), bytes.size.toLong())) {
|
||||
rotate()
|
||||
}
|
||||
val file = fopen(activePath, "ab") ?: return
|
||||
try {
|
||||
bytes.usePinned { pinned ->
|
||||
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
|
||||
}
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
override fun listLogFiles(): List<LogFileInfo> {
|
||||
ensureDirectory()
|
||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.startsWith("app") && it.endsWith(".log") }
|
||||
return names
|
||||
.map { name ->
|
||||
val path = "$directory/$name"
|
||||
LogFileInfo(name, path, fileSize(path), modifiedAt(path))
|
||||
}
|
||||
.sortedByDescending { it.modifiedAtMillis }
|
||||
}
|
||||
|
||||
override fun readLatest(maxBytes: Long): String {
|
||||
if (maxBytes <= 0) return ""
|
||||
ensureDirectory()
|
||||
val paths = listOf(activePath) +
|
||||
(1..policy.maxFiles).map { "$directory/app.$it.log" }
|
||||
val chunks = ArrayList<ByteArray>()
|
||||
var remaining = maxBytes
|
||||
for (path in paths) {
|
||||
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
|
||||
val slice = readTail(path, remaining)
|
||||
if (slice.isEmpty()) continue
|
||||
chunks.add(0, slice)
|
||||
remaining -= slice.size.toLong()
|
||||
}
|
||||
if (chunks.isEmpty()) return ""
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(out, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
return out.decodeToString()
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
if (policy.maxFiles == 0) {
|
||||
fileManager.removeItemAtPath(activePath, null)
|
||||
return
|
||||
}
|
||||
fileManager.removeItemAtPath("$directory/app.${policy.maxFiles}.log", null)
|
||||
for (index in policy.maxFiles - 1 downTo 1) {
|
||||
val source = "$directory/app.$index.log"
|
||||
if (fileManager.fileExistsAtPath(source)) {
|
||||
fileManager.moveItemAtPath(source, "$directory/app.${index + 1}.log", null)
|
||||
}
|
||||
}
|
||||
if (fileManager.fileExistsAtPath(activePath)) {
|
||||
fileManager.moveItemAtPath(activePath, "$directory/app.1.log", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
|
||||
private fun fileSize(path: String): Long {
|
||||
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
|
||||
return (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
|
||||
}
|
||||
|
||||
private fun modifiedAt(path: String): Long {
|
||||
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
|
||||
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
||||
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
||||
}
|
||||
|
||||
private fun readTail(path: String, maxBytes: Long): ByteArray {
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||
val all = data.toByteArray()
|
||||
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
|
||||
if (all.size.toLong() <= maxBytes) return all
|
||||
val start = all.size - maxBytes.toInt()
|
||||
val slice = all.copyOfRange(start, all.size)
|
||||
val newline = slice.indexOf('\n'.code.toByte())
|
||||
return if (newline in 0 until slice.lastIndex) {
|
||||
slice.copyOfRange(newline + 1, slice.size)
|
||||
} else {
|
||||
slice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ByteArray(0)
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ByteArray(0)
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.vnidrop.app.notifications
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import platform.Foundation.NSURL
|
||||
import platform.UIKit.UIApplication
|
||||
import platform.UIKit.UIApplicationOpenNotificationSettingsURLString
|
||||
import platform.UserNotifications.UNAuthorizationOptionAlert
|
||||
import platform.UserNotifications.UNAuthorizationOptionSound
|
||||
import platform.UserNotifications.UNAuthorizationStatusAuthorized
|
||||
import platform.UserNotifications.UNAuthorizationStatusDenied
|
||||
import platform.UserNotifications.UNAuthorizationStatusEphemeral
|
||||
import platform.UserNotifications.UNAuthorizationStatusNotDetermined
|
||||
import platform.UserNotifications.UNAuthorizationStatusProvisional
|
||||
import platform.UserNotifications.UNMutableNotificationContent
|
||||
import platform.UserNotifications.UNNotificationRequest
|
||||
import platform.UserNotifications.UNUserNotificationCenter
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class IosLocalNotificationService : LocalNotificationService {
|
||||
private val center = UNUserNotificationCenter.currentNotificationCenter()
|
||||
private val _permission = MutableStateFlow(NotificationPermission.NotDetermined)
|
||||
override val permission: StateFlow<NotificationPermission> = _permission.asStateFlow()
|
||||
|
||||
override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation ->
|
||||
center.getNotificationSettingsWithCompletionHandler { settings ->
|
||||
val mapped = when (settings?.authorizationStatus) {
|
||||
UNAuthorizationStatusAuthorized,
|
||||
UNAuthorizationStatusProvisional,
|
||||
UNAuthorizationStatusEphemeral -> NotificationPermission.Granted
|
||||
UNAuthorizationStatusDenied -> NotificationPermission.Denied
|
||||
UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined
|
||||
else -> NotificationPermission.Unsupported
|
||||
}
|
||||
_permission.value = mapped
|
||||
if (continuation.isActive) continuation.resume(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestPermission(): NotificationPermission {
|
||||
val current = refreshPermission()
|
||||
if (current != NotificationPermission.NotDetermined) return current
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
center.requestAuthorizationWithOptions(
|
||||
options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound,
|
||||
completionHandler = { granted, _ ->
|
||||
val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
|
||||
_permission.value = result
|
||||
if (continuation.isActive) continuation.resume(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openSettings(): Result<Unit> {
|
||||
val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
|
||||
?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
|
||||
val opened = suspendCancellableCoroutine { continuation ->
|
||||
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
|
||||
if (continuation.isActive) continuation.resume(success)
|
||||
}
|
||||
}
|
||||
return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings"))
|
||||
}
|
||||
|
||||
override suspend fun publish(notification: LocalNotification): Result<Unit> = runCatching {
|
||||
check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
|
||||
val content = UNMutableNotificationContent().apply {
|
||||
setTitle(notification.title)
|
||||
setBody(notification.body)
|
||||
setSound(platform.UserNotifications.UNNotificationSound.defaultSound)
|
||||
}
|
||||
val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null)
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
center.addNotificationRequest(request) { error ->
|
||||
if (!continuation.isActive) return@addNotificationRequest
|
||||
if (error == null) {
|
||||
continuation.resume(Unit)
|
||||
} else {
|
||||
continuation.resumeWith(
|
||||
Result.failure(IllegalStateException(error.localizedDescription)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel(id: String) {
|
||||
center.removePendingNotificationRequestsWithIdentifiers(listOf(id))
|
||||
center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
|
||||
}
|
||||
|
||||
override suspend fun cancelAll() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
center.removeAllDeliveredNotifications()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
|
||||
@Composable
|
||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
||||
SideEffect {
|
||||
// The Swift host owns the actual UIKit status bar style. Compose publishes
|
||||
// the resolved theme here so the wrapper can update without coupling common
|
||||
// UI code to iOS-specific view controller APIs.
|
||||
NSNotificationCenter.defaultCenter.postNotificationName(
|
||||
aName = "VniDropThemeChanged",
|
||||
`object` = null,
|
||||
userInfo = mapOf("isDark" to if (isDarkTheme) "true" else "false"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
import platform.Foundation.NSTemporaryDirectory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.VnidropCore
|
||||
|
||||
class SharedLogicIOSTest {
|
||||
|
||||
@Test
|
||||
fun generatedBindingsCanInitializeRustCore() {
|
||||
val core = VnidropCore.initialize(
|
||||
appDataDir = NSTemporaryDirectory() + "vnidrop-ios-test",
|
||||
eventSink = object : CoreEventSink {
|
||||
override fun onEvent(event: CoreEvent) = Unit
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
assertTrue(core.status().endpointId.isNotBlank())
|
||||
} finally {
|
||||
core.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import uniffi.vnidrop.SourceKind
|
||||
|
||||
class FileSystemServiceIosTest {
|
||||
@Test
|
||||
fun sandboxPickerCopyMapsToPathSource() {
|
||||
val picked = PickedShareFile(
|
||||
value = "/tmp/VniDrop/photos",
|
||||
displayName = "photos",
|
||||
isTemporaryCopy = true,
|
||||
isDirectory = true,
|
||||
)
|
||||
|
||||
val source = picked.toIosShareSource()
|
||||
|
||||
assertEquals(SourceKind.PATH, source.kind)
|
||||
assertEquals(picked.value, source.value)
|
||||
assertEquals(picked.displayName, source.displayName)
|
||||
assertTrue(source.isDirectory)
|
||||
}
|
||||
}
|
||||
@@ -128,44 +128,11 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun pickDirectory(title: String): File? =
|
||||
if (isMacOs()) {
|
||||
val dialog = withMacDirectoryDialog {
|
||||
nativeFileDialog(title).apply { isVisible = true }
|
||||
}
|
||||
try {
|
||||
val directory = dialog.directory ?: return null
|
||||
dialog.file
|
||||
?.let { File(directory, it) }
|
||||
?: File(directory)
|
||||
} finally {
|
||||
dialog.dispose()
|
||||
}
|
||||
} else {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
|
||||
}
|
||||
|
||||
private fun <T> withMacDirectoryDialog(block: () -> T): T {
|
||||
if (!isMacOs()) return block()
|
||||
|
||||
val key = "apple.awt.fileDialogForDirectories"
|
||||
val previous = System.getProperty(key)
|
||||
System.setProperty(key, "true")
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty(key)
|
||||
} else {
|
||||
System.setProperty(key, previous)
|
||||
}
|
||||
private fun pickDirectory(title: String): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
}
|
||||
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
|
||||
}
|
||||
|
||||
private fun isMacOs(): Boolean =
|
||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
@Composable
|
||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
object : TransferShareActions {
|
||||
override val canUseNativeShare = DesktopShareBridge.shareFile != null
|
||||
override val canUseNativeShare = false
|
||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
@@ -31,18 +31,7 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
}
|
||||
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
EventQueue.invokeLater {
|
||||
val share = DesktopShareBridge.shareFile
|
||||
if (share == null) {
|
||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||
return@invokeLater
|
||||
}
|
||||
onResult(runCatching {
|
||||
val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() }
|
||||
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
|
||||
share(file).getOrThrow()
|
||||
})
|
||||
}
|
||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
@@ -55,8 +44,3 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
private fun activeFrame(): Frame? =
|
||||
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
||||
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
|
||||
|
||||
object DesktopShareBridge {
|
||||
@Volatile
|
||||
var shareFile: ((File) -> Result<Unit>)? = null
|
||||
}
|
||||
|
||||
@@ -1,83 +1,14 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import java.awt.Color
|
||||
import java.awt.EventQueue
|
||||
import java.awt.Frame
|
||||
import java.awt.Window
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.JRootPane
|
||||
|
||||
@Composable
|
||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
||||
SideEffect {
|
||||
DesktopSystemAppearance.apply(isDarkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
internal object DesktopSystemAppearance {
|
||||
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
|
||||
private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent"
|
||||
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
|
||||
private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible"
|
||||
|
||||
fun apply(isDarkTheme: Boolean) {
|
||||
if (!DesktopAppearanceBridge.isMacOs()) return
|
||||
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
||||
EventQueue.invokeLater {
|
||||
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
|
||||
Window.getWindows().forEach { window ->
|
||||
applyWindowChrome(window, isDarkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
||||
|
||||
internal fun usesTransparentTitlebar(): Boolean = true
|
||||
internal fun usesFullWindowContent(): Boolean = true
|
||||
internal fun showsNativeWindowTitle(): Boolean = false
|
||||
|
||||
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
||||
if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3)
|
||||
|
||||
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
|
||||
val background = titlebarBackground(isDarkTheme)
|
||||
window.background = background
|
||||
(window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) }
|
||||
}
|
||||
|
||||
internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) {
|
||||
// Extending the Compose surface beneath the native titlebar lets the
|
||||
// window chrome and sidebar share one uninterrupted background.
|
||||
rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent())
|
||||
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
|
||||
rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle())
|
||||
rootPane.background = background
|
||||
rootPane.contentPane.background = background
|
||||
}
|
||||
}
|
||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
|
||||
|
||||
object DesktopAppearanceBridge {
|
||||
@Volatile
|
||||
var applyNativeAppearance: ((Boolean) -> Unit)? = null
|
||||
|
||||
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
|
||||
fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
|
||||
|
||||
fun toggleMaximized(window: Window) {
|
||||
if (!isMacOs()) return
|
||||
val frame = window as? Frame ?: return
|
||||
EventQueue.invokeLater {
|
||||
frame.extendedState = toggledWindowState(frame.extendedState)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isMacOs(osName: String): Boolean =
|
||||
osName.startsWith("Mac", ignoreCase = true)
|
||||
|
||||
internal fun isLinux(osName: String): Boolean =
|
||||
osName.startsWith("Linux", ignoreCase = true)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
import java.awt.Frame
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopAppearanceBridgeTest {
|
||||
@Test
|
||||
fun customWindowChromeIsLimitedToLinux() {
|
||||
assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X"))
|
||||
assertTrue(DesktopAppearanceBridge.isLinux("Linux"))
|
||||
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titlebarDoubleClickTogglesMaximizedWindowState() {
|
||||
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
|
||||
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.Frame
|
||||
import javax.swing.JRootPane
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopSystemAppearanceTest {
|
||||
@Test
|
||||
fun customWindowChromeSupportsMacOsAndLinux() {
|
||||
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X"))
|
||||
assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X"))
|
||||
assertTrue(DesktopAppearanceBridge.isLinux("Linux"))
|
||||
assertFalse(DesktopAppearanceBridge.isMacOs("Linux"))
|
||||
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
|
||||
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titlebarDoubleClickTogglesMaximizedWindowState() {
|
||||
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
|
||||
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun macOsAppearanceNamesMatchResolvedTheme() {
|
||||
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
|
||||
assertEquals("NSAppearanceNameAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titlebarBackgroundMatchesSidebarSurface() {
|
||||
assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
||||
assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transparentTitlebarIsAlwaysUsedWithAppKitAppearance() {
|
||||
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun composeContentExtendsUnderMacOsTitlebar() {
|
||||
val rootPane = JRootPane()
|
||||
val background = Color(0x21, 0x21, 0x21)
|
||||
|
||||
DesktopSystemAppearance.applyRootPaneChrome(rootPane, background)
|
||||
|
||||
assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent"))
|
||||
assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar"))
|
||||
assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible"))
|
||||
assertEquals(background, rootPane.background)
|
||||
assertEquals(background, rootPane.contentPane.background)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runtimeAppearanceCallIsFailSoft() {
|
||||
DesktopSystemAppearance.apply(isDarkTheme = true)
|
||||
DesktopSystemAppearance.apply(isDarkTheme = false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user