refactor(platform): remove Apple targets from KMP
5
.github/workflows/shared-kmp.yml
vendored
@@ -44,8 +44,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
jvm-test:
|
jvm-test:
|
||||||
# Host Rust embedding is enabled only for the current Gobley host target.
|
# Host Rust embedding is enabled only for the current Gobley host target.
|
||||||
# This job stays on macOS to cover the Apple targets as well as JVM tests.
|
runs-on: ubuntu-latest
|
||||||
runs-on: macos-latest
|
|
||||||
timeout-minutes: 75
|
timeout-minutes: 75
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -63,7 +62,7 @@ jobs:
|
|||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
- name: Cache Cargo
|
- name: Cache Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
21
AGENTS.md
@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop).
|
VniDrop is a cross-platform **local P2P file transfer** app.
|
||||||
|
|
||||||
| Layer | Path | Responsibility |
|
| Layer | Path | Responsibility |
|
||||||
|-------|------|----------------|
|
|-------|------|----------------|
|
||||||
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
||||||
| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges |
|
| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
|
||||||
| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells |
|
| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells |
|
||||||
|
| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings |
|
||||||
|
|
||||||
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
||||||
Do not design features that move transfer payloads through Kotlin heap by default.
|
Do not design features that move transfer payloads through Kotlin heap by default.
|
||||||
@@ -53,7 +54,7 @@ Domain docs (reference, do not paste into PRs):
|
|||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
|
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
|
||||||
Android NDK/SDK only if building Android, Xcode only for iOS.
|
Android NDK/SDK only if building Android, Xcode only for the native Apple app.
|
||||||
|
|
||||||
### Rust core (`crates/vnidrop` or workspace root)
|
### Rust core (`crates/vnidrop` or workspace root)
|
||||||
|
|
||||||
@@ -95,15 +96,13 @@ Other targets (slower / machine-dependent):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
./gradlew :shared:testAndroidHostTest
|
||||||
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
|
|
||||||
./gradlew :androidApp:assembleDebug
|
./gradlew :androidApp:assembleDebug
|
||||||
./gradlew :desktopApp:run
|
./gradlew :desktopApp:run
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled
|
**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
|
||||||
for the current host and architecture, so local Linux and Windows builds embed
|
current host and architecture, so local desktop builds embed their matching
|
||||||
their matching desktop Rust library. Prefer macOS only when exact CI parity is
|
Rust library.
|
||||||
required.
|
|
||||||
|
|
||||||
### What to run before finishing
|
### What to run before finishing
|
||||||
|
|
||||||
@@ -144,12 +143,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/
|
|||||||
core/ # CoreGateway, models, pickers interfaces
|
core/ # CoreGateway, models, pickers interfaces
|
||||||
feature/send|receive|approvals|settings|app/
|
feature/send|receive|approvals|settings|app/
|
||||||
ui/theme|components|navigation|feedback|state/
|
ui/theme|components|navigation|feedback|state/
|
||||||
androidMain|iosMain|jvmMain/ # expect/actual implementations
|
androidMain|jvmMain/ # expect/actual implementations
|
||||||
```
|
```
|
||||||
|
|
||||||
### Platform file rules (do not violate)
|
### Platform file rules (do not violate)
|
||||||
|
|
||||||
- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`.
|
- Windows/Linux desktop: paths; directory walk in Rust when `is_directory`.
|
||||||
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
||||||
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
||||||
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Install the tools needed for the area you plan to change:
|
|||||||
- JDK 17 or newer for Gradle and application builds
|
- JDK 17 or newer for Gradle and application builds
|
||||||
- Rust stable with `rustfmt` and Clippy for the transfer core
|
- Rust stable with `rustfmt` and Clippy for the transfer core
|
||||||
- Android SDK and NDK for Android builds
|
- Android SDK and NDK for Android builds
|
||||||
- Xcode on macOS for iOS builds and simulator tests
|
- Xcode and XcodeGen on macOS for native Apple builds and simulator tests
|
||||||
- Node.js 22.12 or newer for the optional diagnostics service
|
- Node.js 22.12 or newer for the optional diagnostics service
|
||||||
|
|
||||||
The first Rust and Gradle builds may take several minutes while dependencies are
|
The first Rust and Gradle builds may take several minutes while dependencies are
|
||||||
@@ -49,10 +49,10 @@ downloaded and native components are compiled.
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
||||||
| `shared/` | Shared Kotlin Multiplatform UI and platform bridges |
|
| `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux |
|
||||||
| `androidApp/` | Android application shell |
|
| `androidApp/` | Android application shell |
|
||||||
| `iosApp/` | iOS application shell |
|
| `desktopApp/` | Windows/Linux JVM application shell |
|
||||||
| `desktopApp/` | Desktop JVM application shell |
|
| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
|
||||||
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
||||||
|
|
||||||
Read the nearest contributor guidance before editing:
|
Read the nearest contributor guidance before editing:
|
||||||
@@ -114,10 +114,22 @@ Platform-specific checks may also be appropriate:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
./gradlew :shared:testAndroidHostTest
|
||||||
./gradlew :shared:iosSimulatorArm64Test
|
|
||||||
./gradlew :androidApp:assembleDebug
|
./gradlew :androidApp:assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Native Apple App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apple
|
||||||
|
./scripts/build-core.sh debug
|
||||||
|
xcodegen generate
|
||||||
|
xcodebuild test \
|
||||||
|
-project VniDrop.xcodeproj \
|
||||||
|
-scheme VniDrop \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
CODE_SIGNING_ALLOWED=NO
|
||||||
|
```
|
||||||
|
|
||||||
### Diagnostics Service
|
### Diagnostics Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
12
README.md
@@ -88,7 +88,8 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Per-receiver requests, approvals, progress, and delivery status
|
- Per-receiver requests, approvals, progress, and delivery status
|
||||||
- Cancel, stop sharing, and local transfer history
|
- Cancel, stop sharing, and local transfer history
|
||||||
- Safe receive destinations that do not silently overwrite existing files
|
- Safe receive destinations that do not silently overwrite existing files
|
||||||
- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
|
Windows, and Linux
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||||
excluded
|
excluded
|
||||||
|
|
||||||
@@ -117,14 +118,17 @@ if you want to try the current version.
|
|||||||
git clone https://github.com/vnidrop/vnidrop.git
|
git clone https://github.com/vnidrop/vnidrop.git
|
||||||
cd vnidrop
|
cd vnidrop
|
||||||
|
|
||||||
# Desktop
|
# Windows/Linux desktop
|
||||||
./gradlew :desktopApp:run
|
./gradlew :desktopApp:run
|
||||||
|
|
||||||
# Android debug build
|
# Android debug build
|
||||||
./gradlew :androidApp:assembleDebug
|
./gradlew :androidApp:assembleDebug
|
||||||
|
|
||||||
# iOS
|
# iOS, iPadOS, and macOS
|
||||||
open iosApp/iosApp.xcodeproj
|
cd apple
|
||||||
|
./scripts/build-core.sh debug
|
||||||
|
xcodegen generate
|
||||||
|
open VniDrop.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
|
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
|
||||||
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
|
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
|
||||||
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
|
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
|
||||||
UI screen-for-screen. Android and desktop JVM continue to use `shared/` + Compose.
|
UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`.
|
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||||
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||||
@main
|
@main
|
||||||
struct VniDropApp: App {
|
struct VniDropApp: App {
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ struct LocalNotification {
|
|||||||
let body: String
|
let body: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local notification service, ported from `LocalNotificationService.kt` /
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
/// `.ios.kt`, backed by `UNUserNotificationCenter`.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocalNotificationService: ObservableObject {
|
final class LocalNotificationService: ObservableObject {
|
||||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ReceiveInvitationActions: AnyObject {
|
protocol ReceiveInvitationActions: AnyObject {
|
||||||
var fileAvailability: ReceiveMethodAvailability { get }
|
var fileAvailability: ReceiveMethodAvailability { get }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Persisted per-transfer thumbnail store, ported from
|
/// Native Apple preview cache and thumbnail loader.
|
||||||
/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`.
|
|
||||||
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
||||||
struct PreviewStoragePolicy {
|
struct PreviewStoragePolicy {
|
||||||
var maxEntryBytes: Int = 512 * 1024
|
var maxEntryBytes: Int = 512 * 1024
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
/// Invitation delivery actions shared by the native Apple feature models.
|
||||||
/// Platform implementations perform export, native share, and NFC write.
|
/// Platform implementations perform export, native share, and NFC write.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TransferShareActions: AnyObject {
|
protocol TransferShareActions: AnyObject {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VnidropCore
|
import VnidropCore
|
||||||
|
|
||||||
/// iOS file system service, ported from `FileSystemService.ios.kt`.
|
/// Native iOS file system service.
|
||||||
/// App-owned Documents is the fixed receive folder; custom folders are not
|
/// App-owned Documents is the fixed receive folder; custom folders are not
|
||||||
/// supported because raw external picker URLs do not survive relaunch.
|
/// supported because raw external picker URLs do not survive relaunch.
|
||||||
struct IosFileSystemService: FileSystemService {
|
struct IosFileSystemService: FileSystemService {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
|
/// Builds a `.vnd` invitation filename from a transfer name.
|
||||||
/// helper in `TransferShareActions.ios.kt`.
|
|
||||||
func invitationFileName(_ transferName: String) -> String {
|
func invitationFileName(_ transferName: String) -> String {
|
||||||
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let base = trimmed.isEmpty ? "invitation" : trimmed
|
let base = trimmed.isEmpty ? "invitation" : trimmed
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import UniformTypeIdentifiers
|
|||||||
@MainActor
|
@MainActor
|
||||||
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
||||||
|
|
||||||
/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`:
|
/// iOS invitation acquisition:
|
||||||
/// document picker, camera QR scanner, and NFC read.
|
/// document picker, camera QR scanner, and NFC read.
|
||||||
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
||||||
private var documentResult: ((Result<String, Error>) -> Void)?
|
private var documentResult: ((Result<String, Error>) -> Void)?
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
||||||
|
|
||||||
/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: export via
|
/// iOS invitation delivery: export via
|
||||||
/// document picker, native share via `UIActivityViewController`, and NFC write.
|
/// document picker, native share via `UIActivityViewController`, and NFC write.
|
||||||
final class IosTransferShareActions: NSObject, TransferShareActions {
|
final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||||
private var nfcWriter: InvitationNfcWriter?
|
private var nfcWriter: InvitationNfcWriter?
|
||||||
@@ -61,8 +61,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a VniDrop invitation to a writable NDEF tag, ported from
|
/// Writes a VniDrop invitation to a writable NDEF tag.
|
||||||
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
|
|
||||||
// Runs entirely on the NFC session's `.main` delegate queue.
|
// Runs entirely on the NFC session's `.main` delegate queue.
|
||||||
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
||||||
private let ticket: String
|
private let ticket: String
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- iOS: NFC NDEF reading (matches the original iosApp entitlements). -->
|
<!-- iOS: NFC NDEF reading. -->
|
||||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
<array>
|
<array>
|
||||||
<string>NDEF</string>
|
<string>NDEF</string>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="1024" height="1024" fill="#FFFFFF"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="40" y="40" width="944" height="944" rx="210" fill="#FFFFFF" stroke="#E9E7F0" stroke-width="8"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -51,7 +51,7 @@ bytes through Kotlin memory.
|
|||||||
- Desktop uses normal filesystem paths.
|
- Desktop uses normal filesystem paths.
|
||||||
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
|
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
|
||||||
descriptor; Rust duplicates the descriptor before streaming.
|
descriptor; Rust duplicates the descriptor before streaming.
|
||||||
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
|
- iOS starts the security-scoped URL lease in Swift and keeps it alive while
|
||||||
Rust streams from the accessible file URL/path.
|
Rust streams from the accessible file URL/path.
|
||||||
|
|
||||||
## Durability And Filesystem Policy
|
## Durability And Filesystem Policy
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ dependencies {
|
|||||||
|
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
implementation(libs.kotlinx.coroutinesSwing)
|
||||||
implementation(libs.jna)
|
|
||||||
|
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
testImplementation(libs.kotlin.testJunit)
|
testImplementation(libs.kotlin.testJunit)
|
||||||
@@ -37,16 +36,12 @@ compose.desktop {
|
|||||||
buildTypes.release.proguard.isEnabled.set(false)
|
buildTypes.release.proguard.isEnabled.set(false)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
|
targetFormats(TargetFormat.Deb, TargetFormat.Rpm)
|
||||||
packageName = "VniDrop"
|
packageName = "VniDrop"
|
||||||
packageVersion = appVersion
|
packageVersion = appVersion
|
||||||
description = "Send files directly across your devices"
|
description = "Send files directly across your devices"
|
||||||
vendor = "Sudosy Labs"
|
vendor = "Sudosy Labs"
|
||||||
licenseFile.set(project.file("../LICENSE"))
|
licenseFile.set(project.file("../LICENSE"))
|
||||||
macOS {
|
|
||||||
bundleID = "com.vnidrop.app"
|
|
||||||
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
|
||||||
}
|
|
||||||
windows {
|
windows {
|
||||||
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import com.sun.jna.Callback
|
|
||||||
import com.sun.jna.Library
|
|
||||||
import com.sun.jna.Native
|
|
||||||
import com.sun.jna.NativeLibrary
|
|
||||||
import com.sun.jna.Pointer
|
|
||||||
import com.sun.jna.Structure
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
internal object MacOsAppKitAppearance {
|
|
||||||
private val objc: ObjCRuntime? by lazy {
|
|
||||||
runCatching {
|
|
||||||
NativeLibrary.getInstance("AppKit")
|
|
||||||
Native.load("objc", ObjCRuntime::class.java)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply(isDarkTheme: Boolean) {
|
|
||||||
if (!isMacOs()) return
|
|
||||||
runCatching {
|
|
||||||
val runtime = objc ?: return
|
|
||||||
val applicationClass = runtime.objc_getClass("NSApplication") ?: return
|
|
||||||
val appearanceClass = runtime.objc_getClass("NSAppearance") ?: return
|
|
||||||
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) ?: return
|
|
||||||
val appearanceName = nsString(runtime, macOsAppearanceName(isDarkTheme)) ?: return
|
|
||||||
val appearance = runtime.objc_msgSend(
|
|
||||||
appearanceClass,
|
|
||||||
runtime.sel_registerName("appearanceNamed:"),
|
|
||||||
appearanceName,
|
|
||||||
) ?: return
|
|
||||||
runtime.objc_msgSend(application, runtime.sel_registerName("setAppearance:"), appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
|
||||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
|
||||||
|
|
||||||
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
|
|
||||||
val stringClass = runtime.objc_getClass("NSString") ?: return null
|
|
||||||
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isMacOs(): Boolean =
|
|
||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object MacOsShareSheet {
|
|
||||||
private val objc: ObjCRuntime? by lazy {
|
|
||||||
runCatching {
|
|
||||||
NativeLibrary.getInstance("AppKit")
|
|
||||||
Native.load("objc", ObjCRuntime::class.java)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
private var retainedPicker: Pointer? = null
|
|
||||||
private val systemLibrary: NativeLibrary? by lazy {
|
|
||||||
runCatching { NativeLibrary.getInstance("System") }.getOrNull()
|
|
||||||
}
|
|
||||||
private val dispatch: DispatchRuntime? by lazy {
|
|
||||||
runCatching { Native.load("System", DispatchRuntime::class.java) }.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun share(file: File): Result<Unit> = runCatching {
|
|
||||||
require(file.isFile) { "The invitation file could not be created" }
|
|
||||||
var failure: Throwable? = null
|
|
||||||
val runtime = dispatch ?: error("The macOS main queue is unavailable")
|
|
||||||
// dispatch_get_main_queue() is a C macro on Darwin, so there is no
|
|
||||||
// function for dlsym/JNA to resolve. The macro returns this exported
|
|
||||||
// queue object directly.
|
|
||||||
val queue = systemLibrary?.getGlobalVariableAddress("_dispatch_main_q")
|
|
||||||
?: error("The macOS main queue is unavailable")
|
|
||||||
runtime.dispatch_sync_f(queue, null, DispatchWork { failure = runCatching { show(file) }.exceptionOrNull() })
|
|
||||||
failure?.let { throw it }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun show(file: File) {
|
|
||||||
val runtime = objc ?: error("AppKit is unavailable")
|
|
||||||
val applicationClass = runtime.objc_getClass("NSApplication") ?: error("NSApplication is unavailable")
|
|
||||||
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication"))
|
|
||||||
?: error("NSApplication could not be opened")
|
|
||||||
val window = runtime.objc_msgSend(application, runtime.sel_registerName("keyWindow"))
|
|
||||||
?: runtime.objc_msgSend(application, runtime.sel_registerName("mainWindow"))
|
|
||||||
?: error("No active macOS window")
|
|
||||||
val contentView = runtime.objc_msgSend(window, runtime.sel_registerName("contentView"))
|
|
||||||
?: error("The active window has no content view")
|
|
||||||
val path = nsString(runtime, file.absolutePath) ?: error("The invitation path is invalid")
|
|
||||||
val urlClass = runtime.objc_getClass("NSURL") ?: error("NSURL is unavailable")
|
|
||||||
val url = runtime.objc_msgSend(urlClass, runtime.sel_registerName("fileURLWithPath:"), path)
|
|
||||||
?: error("The invitation URL could not be created")
|
|
||||||
val arrayClass = runtime.objc_getClass("NSArray") ?: error("NSArray is unavailable")
|
|
||||||
val items = runtime.objc_msgSend(arrayClass, runtime.sel_registerName("arrayWithObject:"), url)
|
|
||||||
?: error("The share item could not be created")
|
|
||||||
val pickerClass = runtime.objc_getClass("NSSharingServicePicker") ?: error("The macOS share sheet is unavailable")
|
|
||||||
val allocated = runtime.objc_msgSend(pickerClass, runtime.sel_registerName("alloc"))
|
|
||||||
?: error("The macOS share sheet could not be allocated")
|
|
||||||
val picker = runtime.objc_msgSend(allocated, runtime.sel_registerName("initWithItems:"), items)
|
|
||||||
?: error("The macOS share sheet could not be created")
|
|
||||||
retainedPicker?.let { runtime.objc_msgSend(it, runtime.sel_registerName("release")) }
|
|
||||||
retainedPicker = picker
|
|
||||||
runtime.objc_msgSend(
|
|
||||||
picker,
|
|
||||||
runtime.sel_registerName("showRelativeToRect:ofView:preferredEdge:"),
|
|
||||||
anchorRect(),
|
|
||||||
contentView,
|
|
||||||
3L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
|
|
||||||
val stringClass = runtime.objc_getClass("NSString") ?: return null
|
|
||||||
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun validateNativeRectMapping(): Int = anchorRect().size()
|
|
||||||
internal fun hasNativeMainQueue(): Boolean =
|
|
||||||
runCatching { systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") != null }.getOrDefault(false)
|
|
||||||
|
|
||||||
private fun anchorRect() = NSRectByValue().apply {
|
|
||||||
x = 0.0
|
|
||||||
y = 0.0
|
|
||||||
width = 1.0
|
|
||||||
height = 1.0
|
|
||||||
write()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Structure.FieldOrder("x", "y", "width", "height")
|
|
||||||
internal class NSRectByValue : Structure(), Structure.ByValue {
|
|
||||||
@JvmField var x: Double = 0.0
|
|
||||||
@JvmField var y: Double = 0.0
|
|
||||||
@JvmField var width: Double = 0.0
|
|
||||||
@JvmField var height: Double = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface ObjCRuntime : Library {
|
|
||||||
fun objc_getClass(name: String): Pointer?
|
|
||||||
fun sel_registerName(name: String): Pointer
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer?
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer?
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer?
|
|
||||||
fun objc_msgSend(
|
|
||||||
receiver: Pointer?,
|
|
||||||
selector: Pointer?,
|
|
||||||
rect: NSRectByValue,
|
|
||||||
view: Pointer?,
|
|
||||||
edge: Long,
|
|
||||||
): Pointer?
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun interface DispatchWork : Callback {
|
|
||||||
fun invoke(context: Pointer?)
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface DispatchRuntime : Library {
|
|
||||||
fun dispatch_sync_f(queue: Pointer?, context: Pointer?, work: DispatchWork)
|
|
||||||
}
|
|
||||||
@@ -47,31 +47,23 @@ import androidx.compose.ui.window.WindowPlacement
|
|||||||
import androidx.compose.ui.window.WindowScope
|
import androidx.compose.ui.window.WindowScope
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
|
||||||
import com.vnidrop.app.feature.send.DesktopShareBridge
|
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||||
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
||||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||||
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
||||||
|
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val externalInvitations = ExternalInvitationController()
|
val externalInvitations = ExternalInvitationController()
|
||||||
val macOs = DesktopAppearanceBridge.isMacOs()
|
|
||||||
val linux = DesktopAppearanceBridge.isLinux()
|
val linux = DesktopAppearanceBridge.isLinux()
|
||||||
val customWindowChrome = macOs || linux
|
|
||||||
configureMacOsNativeAppearance()
|
|
||||||
configureInvitationOpenHandler(externalInvitations)
|
configureInvitationOpenHandler(externalInvitations)
|
||||||
args.asSequence()
|
args.asSequence()
|
||||||
.map(::File)
|
.map(::File)
|
||||||
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
||||||
.forEach { externalInvitations.openFile(it) }
|
.forEach { externalInvitations.openFile(it) }
|
||||||
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
|
||||||
if (macOs) {
|
|
||||||
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
|
||||||
}
|
|
||||||
application {
|
application {
|
||||||
val windowState = rememberWindowState()
|
val windowState = rememberWindowState()
|
||||||
Window(
|
Window(
|
||||||
@@ -83,29 +75,21 @@ fun main(args: Array<String>) {
|
|||||||
) {
|
) {
|
||||||
App(
|
App(
|
||||||
dependencies = rememberJvmAppDependencies(externalInvitations),
|
dependencies = rememberJvmAppDependencies(externalInvitations),
|
||||||
windowChromeTopInset = when {
|
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
|
||||||
macOs -> MacOsTitleBarHeight
|
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
|
||||||
linux -> LinuxTitleBarHeight
|
windowChrome = if (linux) {
|
||||||
else -> 0.dp
|
{
|
||||||
},
|
LinuxTitleBar(
|
||||||
windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp,
|
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
||||||
windowChrome = when {
|
onMinimize = { windowState.isMinimized = true },
|
||||||
macOs -> {
|
onToggleMaximize = {
|
||||||
{ MacOsTitleBar() }
|
windowState.placement = toggledWindowPlacement(windowState.placement)
|
||||||
|
},
|
||||||
|
onClose = ::exitApplication,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
linux -> {
|
} else {
|
||||||
{
|
null
|
||||||
LinuxTitleBar(
|
|
||||||
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
|
||||||
onMinimize = { windowState.isMinimized = true },
|
|
||||||
onToggleMaximize = {
|
|
||||||
windowState.placement = toggledWindowPlacement(windowState.placement)
|
|
||||||
},
|
|
||||||
onClose = ::exitApplication,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -130,55 +114,11 @@ private fun ExternalInvitationController.openFile(file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun configureMacOsNativeAppearance() {
|
|
||||||
if (!DesktopAppearanceBridge.isMacOs()) return
|
|
||||||
// AWT reads this before creating the first native window. Runtime theme
|
|
||||||
// changes are handled in the JVM platform appearance hook.
|
|
||||||
System.setProperty("apple.awt.application.appearance", "system")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MacOsTitleBarHeight = 28.dp
|
|
||||||
private val MacOsTrafficLightsWidth = 76.dp
|
|
||||||
private val LinuxTitleBarHeight = 40.dp
|
private val LinuxTitleBarHeight = 40.dp
|
||||||
private val LinuxWindowControlWidth = 46.dp
|
private val LinuxWindowControlWidth = 46.dp
|
||||||
private val LinuxWindowControlsWidth = 138.dp
|
private val LinuxWindowControlsWidth = 138.dp
|
||||||
private val DesktopContentCornerRadius = 20.dp
|
private val DesktopContentCornerRadius = 20.dp
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
private fun WindowScope.MacOsTitleBar() {
|
|
||||||
val colors = LocalVniDropColors.current
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(MacOsTitleBarHeight)
|
|
||||||
.background(colors.backgroundSurface200),
|
|
||||||
) {
|
|
||||||
WindowDraggableArea(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(start = MacOsTrafficLightsWidth)
|
|
||||||
.onPointerEvent(PointerEventType.Press) { event ->
|
|
||||||
if (event.awtEventOrNull?.clickCount == 2) {
|
|
||||||
DesktopAppearanceBridge.toggleMaximized(window)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
|
|
||||||
BasicText(
|
|
||||||
text = "VniDrop",
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
style = TextStyle(
|
|
||||||
color = colors.foregroundDefault,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun WindowScope.LinuxTitleBar(
|
private fun WindowScope.LinuxTitleBar(
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import org.junit.Assume.assumeTrue
|
|
||||||
|
|
||||||
class MacOsShareSheetTest {
|
|
||||||
@Test
|
|
||||||
fun nativeAnchorRectHasTheCocoaLayout() {
|
|
||||||
assertEquals(32, MacOsShareSheet.validateNativeRectMapping())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativeMainDispatchQueueCanBeResolved() {
|
|
||||||
assumeTrue(DesktopAppearanceBridge.isMacOs())
|
|
||||||
assertTrue(MacOsShareSheet.hasNativeMainQueue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
#Kotlin
|
#Kotlin
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx3072M
|
kotlin.daemon.jvmargs=-Xmx3072M
|
||||||
kotlin.mpp.enableCInteropCommonization=true
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ kotlin = "2.4.0"
|
|||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
qrcode = "4.5.0"
|
qrcode = "4.5.0"
|
||||||
jna = "5.17.0"
|
|
||||||
google-code-scanner = "16.1.0"
|
google-code-scanner = "16.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -47,7 +46,6 @@ kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-co
|
|||||||
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
|
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
|
||||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
|
||||||
google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" }
|
google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
TEAM_ID=
|
|
||||||
|
|
||||||
PRODUCT_NAME=VniDrop
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
|
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION=1
|
|
||||||
MARKETING_VERSION=1.0
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 77;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = B83017A9EC036A038DC1B430 /* iosApp */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */,
|
|
||||||
);
|
|
||||||
path = iosApp;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
B93EE283488EDD1107331E67 /* Configuration */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = Configuration;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
2BC80A7FB47F5EF23FB83738 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
CBA2EB46BC3B70D303C46327 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B93EE283488EDD1107331E67 /* Configuration */,
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */,
|
|
||||||
C13A067056BA9F38FD87A539 /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C13A067056BA9F38FD87A539 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
B83017A9EC036A038DC1B430 /* iosApp */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */;
|
|
||||||
buildPhases = (
|
|
||||||
091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */,
|
|
||||||
C8C88FCA7F60AE3F54E85A8C /* Sources */,
|
|
||||||
2BC80A7FB47F5EF23FB83738 /* Frameworks */,
|
|
||||||
8E81423A1A7357695DF65754 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */,
|
|
||||||
);
|
|
||||||
name = iosApp;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = iosApp;
|
|
||||||
productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
F60687F0AEE04DF31E2599D0 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = 1;
|
|
||||||
LastSwiftUpdateCheck = 1620;
|
|
||||||
LastUpgradeCheck = 1620;
|
|
||||||
TargetAttributes = {
|
|
||||||
B83017A9EC036A038DC1B430 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */;
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = CBA2EB46BC3B70D303C46327;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
preferredProjectObjectVersion = 77;
|
|
||||||
productRefGroup = C13A067056BA9F38FD87A539 /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
B83017A9EC036A038DC1B430 /* iosApp */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
8E81423A1A7357695DF65754 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
|
||||||
091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Compile Kotlin Framework";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport CARGO_PROFILE_DEV_STRIP=none\nexport JAVA_HOME=$(/usr/libexec/java_home)\n\nif [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
C8C88FCA7F60AE3F54E85A8C /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
5EDFED06EDA142275594F3F7 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */;
|
|
||||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
6A37768B9DA3138100D19D47 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */;
|
|
||||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
87E567D3634C99D02D035476 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
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\"";
|
|
||||||
DEVELOPMENT_TEAM = A8A4JSMV5D;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
|
||||||
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;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"-framework",
|
|
||||||
SystemConfiguration,
|
|
||||||
"-framework",
|
|
||||||
Network,
|
|
||||||
"-framework",
|
|
||||||
CoreNFC,
|
|
||||||
"-framework",
|
|
||||||
AVFoundation,
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
A9DAF5A8F7787C0F3BAEC312 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
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\"";
|
|
||||||
DEVELOPMENT_TEAM = A8A4JSMV5D;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
|
||||||
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;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"-framework",
|
|
||||||
SystemConfiguration,
|
|
||||||
"-framework",
|
|
||||||
Network,
|
|
||||||
"-framework",
|
|
||||||
CoreNFC,
|
|
||||||
"-framework",
|
|
||||||
AVFoundation,
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
87E567D3634C99D02D035476 /* Debug */,
|
|
||||||
A9DAF5A8F7787C0F3BAEC312 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
6A37768B9DA3138100D19D47 /* Debug */,
|
|
||||||
5EDFED06EDA142275594F3F7 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
};
|
|
||||||
rootObject = F60687F0AEE04DF31E2599D0 /* Project object */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors": [
|
|
||||||
{
|
|
||||||
"idiom": "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "app-icon.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "tinted"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import Shared
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
// Compose renders the app content, but UIKit owns the status bar style. This
|
|
||||||
// host listens for theme changes from shared Kotlin code and asks iOS to
|
|
||||||
// recompute the status bar contrast.
|
|
||||||
final class VniDropHostViewController: UIViewController {
|
|
||||||
private let composeController: UIViewController
|
|
||||||
private var usesDarkTheme: Bool
|
|
||||||
|
|
||||||
init(composeController: UIViewController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
self.usesDarkTheme = UITraitCollection.current.userInterfaceStyle == .dark
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
usesDarkTheme ? .lightContent : .darkContent
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
addChild(composeController)
|
|
||||||
view.addSubview(composeController.view)
|
|
||||||
composeController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
composeController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
composeController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
composeController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
composeController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
composeController.didMove(toParent: self)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(themeDidChange(_:)),
|
|
||||||
name: Notification.Name("VniDropThemeChanged"),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func themeDidChange(_ notification: Notification) {
|
|
||||||
guard let isDark = notification.userInfo?["isDark"] as? String else { return }
|
|
||||||
usesDarkTheme = isDark == "true"
|
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
|
||||||
let externalInvitations: ExternalInvitationController
|
|
||||||
|
|
||||||
func makeUIViewController(context: Self.Context) -> UIViewController {
|
|
||||||
VniDropHostViewController(
|
|
||||||
composeController: MainViewControllerKt.MainViewController(
|
|
||||||
externalInvitations: externalInvitations
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
let externalInvitations: ExternalInvitationController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ComposeView(externalInvitations: externalInvitations)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onOpenURL(perform: openInvitation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openInvitation(_ url: URL) {
|
|
||||||
guard url.pathExtension.caseInsensitiveCompare("vnd") == .orderedSame else {
|
|
||||||
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasSecurityAccess = url.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if hasSecurityAccess {
|
|
||||||
url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
||||||
if let fileSize = values.fileSize, fileSize > 65_536 {
|
|
||||||
throw InvitationOpenError.tooLarge
|
|
||||||
}
|
|
||||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
|
||||||
guard data.count <= 65_536 else { throw InvitationOpenError.tooLarge }
|
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
|
||||||
throw InvitationOpenError.invalidEncoding
|
|
||||||
}
|
|
||||||
externalInvitations.openInvitation(raw: raw)
|
|
||||||
} catch {
|
|
||||||
externalInvitations.reportOpenFailure(
|
|
||||||
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum InvitationOpenError: LocalizedError {
|
|
||||||
case tooLarge
|
|
||||||
case invalidEncoding
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .tooLarge: "The invitation is too large"
|
|
||||||
case .invalidEncoding: "The invitation is not valid text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?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>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>CFBundleDocumentTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleTypeName</key>
|
|
||||||
<string>VniDrop Invitation</string>
|
|
||||||
<key>CFBundleTypeRole</key>
|
|
||||||
<string>Viewer</string>
|
|
||||||
<key>LSHandlerRank</key>
|
|
||||||
<string>Owner</string>
|
|
||||||
<key>LSItemContentTypes</key>
|
|
||||||
<array>
|
|
||||||
<string>com.vnidrop.app.invitation</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
||||||
<true/>
|
|
||||||
<key>UTExportedTypeDeclarations</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>UTTypeConformsTo</key>
|
|
||||||
<array>
|
|
||||||
<string>public.data</string>
|
|
||||||
</array>
|
|
||||||
<key>UTTypeDescription</key>
|
|
||||||
<string>VniDrop Invitation</string>
|
|
||||||
<key>UTTypeIdentifier</key>
|
|
||||||
<string>com.vnidrop.app.invitation</string>
|
|
||||||
<key>UTTypeTagSpecification</key>
|
|
||||||
<dict>
|
|
||||||
<key>public.filename-extension</key>
|
|
||||||
<array>
|
|
||||||
<string>vnd</string>
|
|
||||||
</array>
|
|
||||||
<key>public.mime-type</key>
|
|
||||||
<string>application/vnd.vnidrop.transfer</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIFileSharingEnabled</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/>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string></string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import Shared
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct iOSApp: App {
|
|
||||||
private let externalInvitations = ExternalInvitationController()
|
|
||||||
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView(externalInvitations: externalInvitations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -7,9 +7,10 @@ still applies; this file wins for UI/KMP work.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and
|
`shared` is the Compose Multiplatform app layer for Android, Windows, and Linux:
|
||||||
`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work
|
Compose UI, feature ViewModels, and `expect`/`actual` platform bridges. Native
|
||||||
goes through UniFFI `VnidropCore` (see `crates/vnidrop`).
|
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%). |
|
| 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`. |
|
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
||||||
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
| 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. |
|
| 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.
|
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
|
||||||
@@ -53,14 +54,12 @@ Optional:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
./gradlew :shared:testAndroidHostTest
|
||||||
./gradlew :shared:iosSimulatorArm64Test
|
|
||||||
./gradlew :desktopApp:run
|
./gradlew :desktopApp:run
|
||||||
./gradlew :androidApp:assembleDebug
|
./gradlew :androidApp:assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
CI `:shared:jvmTest` currently runs on **macOS**. Gobley host cargo follows the
|
CI `:shared:jvmTest` runs on **Linux**. Gobley host cargo follows the current
|
||||||
current host and architecture, including Linux and Windows; use macOS only when
|
host and architecture so local desktop builds embed their matching Rust library.
|
||||||
exact CI parity is required.
|
|
||||||
|
|
||||||
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
||||||
`jvmTest` so Gobley/native pieces stay aligned.
|
`jvmTest` so Gobley/native pieces stay aligned.
|
||||||
@@ -76,7 +75,7 @@ src/
|
|||||||
feature/send|receive|approvals|settings|app/
|
feature/send|receive|approvals|settings|app/
|
||||||
ui/ # theme, components, navigation, feedback, state helpers
|
ui/ # theme, components, navigation, feedback, state helpers
|
||||||
commonMain/composeResources/
|
commonMain/composeResources/
|
||||||
androidMain|iosMain|jvmMain/
|
androidMain|jvmMain/
|
||||||
commonTest|jvmTest|...
|
commonTest|jvmTest|...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,7 +85,8 @@ src/
|
|||||||
documents with relative `displayName` paths before calling Rust
|
documents with relative `displayName` paths before calling Rust
|
||||||
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
||||||
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
|
- **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.
|
- **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk.
|
||||||
|
|
||||||
Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||||
|
|
||||||
import gobley.gradle.cargo.dsl.appleMobile
|
|
||||||
import gobley.gradle.cargo.dsl.jvm
|
import gobley.gradle.cargo.dsl.jvm
|
||||||
import gobley.gradle.cargo.tasks.CargoBuildTask
|
import gobley.gradle.cargo.tasks.CargoBuildTask
|
||||||
import gobley.gradle.cargo.tasks.CargoCheckTask
|
import gobley.gradle.cargo.tasks.CargoCheckTask
|
||||||
import gobley.gradle.GobleyHost
|
import gobley.gradle.GobleyHost
|
||||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||||
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
|
||||||
import gobley.gradle.rust.targets.RustTarget
|
import gobley.gradle.rust.targets.RustTarget
|
||||||
import gobley.gradle.Variant
|
import gobley.gradle.Variant
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
@@ -113,18 +111,6 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
|
||||||
listOf(
|
|
||||||
iosArm64(),
|
|
||||||
iosSimulatorArm64()
|
|
||||||
).forEach { iosTarget ->
|
|
||||||
iosTarget.binaries.framework {
|
|
||||||
baseName = "Shared"
|
|
||||||
isStatic = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
androidTarget {
|
androidTarget {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = JvmTarget.JVM_11
|
jvmTarget = JvmTarget.JVM_11
|
||||||
@@ -190,9 +176,6 @@ android {
|
|||||||
val hostCargoTargets = buildSet<RustTarget> {
|
val hostCargoTargets = buildSet<RustTarget> {
|
||||||
add(GobleyHost.current.rustTarget)
|
add(GobleyHost.current.rustTarget)
|
||||||
addAll(RustAndroidTarget.entries)
|
addAll(RustAndroidTarget.entries)
|
||||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
|
||||||
addAll(RustAppleMobileTarget.entries)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cargo {
|
cargo {
|
||||||
@@ -207,15 +190,6 @@ cargo {
|
|||||||
embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget)
|
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 {
|
builds.configureEach {
|
||||||
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
||||||
installTargetBeforeBuild.set(buildOnCurrentHost)
|
installTargetBeforeBuild.set(buildOnCurrentHost)
|
||||||
|
|||||||
@@ -52,15 +52,13 @@ private class AndroidFileSystemService(
|
|||||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||||
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
||||||
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
||||||
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
||||||
when (folder.kind) {
|
when (folder.kind) {
|
||||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||||
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||||
ReceiveFolderKind.FileSystemPath,
|
ReceiveFolderKind.FileSystemPath -> null
|
||||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sharePickedFiles(
|
override suspend fun sharePickedFiles(
|
||||||
|
|||||||
@@ -134,13 +134,6 @@ interface CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share>
|
): 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. */
|
/** Multi-source share used by multi-file pickers. */
|
||||||
suspend fun shareSources(
|
suspend fun shareSources(
|
||||||
sources: List<uniffi.vnidrop.ShareSource>,
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
@@ -151,7 +144,6 @@ interface CoreGateway {
|
|||||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, 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 cancel(transferId: ULong): Result<Unit>
|
||||||
suspend fun delete(transferId: ULong): Result<Unit>
|
suspend fun delete(transferId: ULong): Result<Unit>
|
||||||
suspend fun clearReceiveHistory(): Result<ULong>
|
suspend fun clearReceiveHistory(): Result<ULong>
|
||||||
|
|||||||
@@ -113,27 +113,6 @@ class CoreRepository(
|
|||||||
accessPolicy = accessPolicy,
|
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 {
|
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||||
_state.update { it.copy(lastInspection = inspection) }
|
_state.update { it.copy(lastInspection = inspection) }
|
||||||
@@ -154,17 +133,6 @@ class CoreRepository(
|
|||||||
refreshSnapshot()
|
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 {
|
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||||
requireCore().cancelTransfer(transferId)
|
requireCore().cancelTransfer(transferId)
|
||||||
refreshSnapshot()
|
refreshSnapshot()
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ data class PickedShareFile(
|
|||||||
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
||||||
val isTemporaryCopy: Boolean = false,
|
val isTemporaryCopy: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
* When true, [value] is a directory (filesystem path or Android document tree
|
||||||
* folder URL, or Android document tree URI). Platform share code expands or
|
* URI). Platform share code expands or walks it; Rust cannot treat an Android
|
||||||
* walks it; Rust cannot treat an Android FD as a directory.
|
* FD as a directory.
|
||||||
*/
|
*/
|
||||||
val isDirectory: Boolean = false,
|
val isDirectory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ enum class ReceiveFolderKind {
|
|||||||
/** Shared system Downloads via MediaStore (Android 10+). */
|
/** Shared system Downloads via MediaStore (Android 10+). */
|
||||||
AndroidPublicDownloads,
|
AndroidPublicDownloads,
|
||||||
AndroidTreeUri,
|
AndroidTreeUri,
|
||||||
IosSecurityScopedUrl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package com.vnidrop.app.core
|
|||||||
|
|
||||||
import uniffi.vnidrop.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
|
|
||||||
// Platform file handles have different lifetime rules. Desktop paths need no
|
// Desktop paths need no extra work, while Rust duplicates borrowed Android file
|
||||||
// extra work, Rust duplicates Android fd sources immediately, and iOS
|
// descriptors immediately before the platform closes them.
|
||||||
// security-scoped URLs must remain leased while Rust performs the blocking
|
|
||||||
// import/export call.
|
|
||||||
internal expect suspend fun <T> withPlatformPathAccess(
|
internal expect suspend fun <T> withPlatformPathAccess(
|
||||||
kind: SourceKind,
|
kind: SourceKind,
|
||||||
value: String,
|
value: String,
|
||||||
|
|||||||
@@ -161,14 +161,10 @@ class ReceiveViewModel(
|
|||||||
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
||||||
}
|
}
|
||||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||||
val result = when {
|
val result = if (outputSink != null) {
|
||||||
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||||
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
|
} else {
|
||||||
current.ticket,
|
repository.receive(current.ticket, folder.value, current.receiverName)
|
||||||
folder.value,
|
|
||||||
current.receiverName,
|
|
||||||
)
|
|
||||||
else -> repository.receive(current.ticket, folder.value, current.receiverName)
|
|
||||||
}
|
}
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ class ViewModelsTest {
|
|||||||
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
|
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 {
|
val preferences = preferences().apply {
|
||||||
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
) = Result.failure<Share>(UnsupportedOperationException())
|
) = 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(
|
override suspend fun shareSources(
|
||||||
sources: List<uniffi.vnidrop.ShareSource>,
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
@@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
awaitReceiveIfNeeded()
|
awaitReceiveIfNeeded()
|
||||||
return receiveResult
|
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> {
|
override suspend fun cancel(transferId: ULong): Result<Unit> {
|
||||||
cancelledTransfers += transferId
|
cancelledTransfers += transferId
|
||||||
return Result.success(Unit)
|
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()
|
}.getOrNull()
|
||||||
|
|
||||||
private fun pickDirectory(title: String): File? =
|
private fun pickDirectory(title: String): File? {
|
||||||
if (isMacOs()) {
|
val chooser = JFileChooser().apply {
|
||||||
val dialog = withMacDirectoryDialog {
|
dialogTitle = title
|
||||||
nativeFileDialog(title).apply { isVisible = true }
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
}
|
isAcceptAllFileFilterUsed = false
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||||
object : TransferShareActions {
|
object : TransferShareActions {
|
||||||
override val canUseNativeShare = DesktopShareBridge.shareFile != null
|
override val canUseNativeShare = false
|
||||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||||
|
|
||||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
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) {
|
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
EventQueue.invokeLater {
|
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
@@ -55,8 +44,3 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|||||||
private fun activeFrame(): Frame? =
|
private fun activeFrame(): Frame? =
|
||||||
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
||||||
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
|
?: 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
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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.Frame
|
||||||
import java.awt.Window
|
|
||||||
import javax.swing.JFrame
|
|
||||||
import javax.swing.JRootPane
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object DesktopAppearanceBridge {
|
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 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 =
|
internal fun isLinux(osName: String): Boolean =
|
||||||
osName.startsWith("Linux", ignoreCase = true)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||