refactor(platform): remove Apple targets from KMP

This commit is contained in:
2026-07-21 17:09:59 +02:00
parent d8eaf78997
commit 9e8564e013
75 changed files with 118 additions and 2930 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)?

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

View File

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

View File

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

View File

@@ -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"))
} }

View File

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

View File

@@ -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,17 +75,9 @@ 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
},
windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp,
windowChrome = when {
macOs -> {
{ MacOsTitleBar() }
}
linux -> {
{ {
LinuxTitleBar( LinuxTitleBar(
isMaximized = windowState.placement == WindowPlacement.Maximized, isMaximized = windowState.placement == WindowPlacement.Maximized,
@@ -104,8 +88,8 @@ fun main(args: Array<String>) {
onClose = ::exitApplication, onClose = ::exitApplication,
) )
} }
} } else {
else -> null 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(

View File

@@ -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())
}
}

View File

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

View File

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

View File

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

View File

@@ -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 */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,11 +0,0 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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"
}
}
}

View File

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

View File

@@ -1,6 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,13 +0,0 @@
import Shared
import SwiftUI
@main
struct iOSApp: App {
private let externalInvitations = ExternalInvitationController()
var body: some Scene {
WindowGroup {
ContentView(externalInvitations: externalInvitations)
}
}
}

View File

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

View File

@@ -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`.

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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,
) )

View File

@@ -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]. */

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

@@ -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(),
)
}

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

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

View File

@@ -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()
}

View File

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

View File

@@ -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"
}

View File

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

View File

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

View File

@@ -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()
}
}

View File

@@ -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"),
)
}
}

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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 dialog = withMacDirectoryDialog {
nativeFileDialog(title).apply { isVisible = true }
}
try {
val directory = dialog.directory ?: return null
dialog.file
?.let { File(directory, it) }
?: File(directory)
} finally {
dialog.dispose()
}
} else {
val chooser = JFileChooser().apply { val chooser = JFileChooser().apply {
dialogTitle = title dialogTitle = title
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false isAcceptAllFileFilterUsed = false
} }
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
} }
private fun <T> withMacDirectoryDialog(block: () -> T): T {
if (!isMacOs()) return block()
val key = "apple.awt.fileDialogForDirectories"
val previous = System.getProperty(key)
System.setProperty(key, "true")
return try {
block()
} finally {
if (previous == null) {
System.clearProperty(key)
} else {
System.setProperty(key, previous)
}
}
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)

View File

@@ -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 {
val share = DesktopShareBridge.shareFile
if (share == null) {
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) 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
}

View File

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

View File

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

View File

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