From b125b70bb2ff3aa14cf5c2095d896714be8ddf3a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:29:35 -0400 Subject: [PATCH 01/58] Add `Asset` --- Sources/AndroidAssetManager/Asset.swift | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 Sources/AndroidAssetManager/Asset.swift diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift new file mode 100644 index 0000000..2faa846 --- /dev/null +++ b/Sources/AndroidAssetManager/Asset.swift @@ -0,0 +1,124 @@ +// +// Asset.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +/// A handle to an `AAsset`. +/// +/// Asset values own their pointer and close it during deinitialization. +public struct Asset: ~Copyable { + + internal let pointer: OpaquePointer + + internal init(_ pointer: OpaquePointer) { + self.pointer = pointer + } + + deinit { + AAsset_close(pointer) + } +} + +// MARK: - Properties + +public extension Asset { + + /// Total uncompressed length of this asset in bytes. + var length: Int64 { + AAsset_getLength64(pointer) + } + + /// Remaining unread bytes in this asset. + var remainingLength: Int64 { + AAsset_getRemainingLength64(pointer) + } + + /// Whether the asset is backed by a memory allocation. + var isAllocated: Bool { + AAsset_isAllocated(pointer) != 0 + } +} + +// MARK: - Methods + +public extension Asset { + + enum SeekOrigin: Int32, Sendable { + case start = 0 + case current = 1 + case end = 2 + } + + /// Reads up to `maxCount` bytes from the current cursor position. + func read(maxCount: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { + guard maxCount > 0 else { + return [] + } + var bytes = [UInt8](repeating: 0, count: maxCount) + let count = AAsset_read(pointer, &bytes, maxCount) + guard count >= 0 else { + throw .readAsset(count) + } + return Array(bytes.prefix(Int(count))) + } + + /// Reads and returns all remaining bytes. + func readAll(chunkSize: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { + guard chunkSize > 0 else { + return [] + } + var output = [UInt8]() + output.reserveCapacity(Int(max(remainingLength, 0))) + while true { + let chunk = try read(maxCount: chunkSize) + if chunk.isEmpty { + break + } + output.append(contentsOf: chunk) + } + return output + } + + /// Seeks the asset cursor and returns the new absolute position. + /// + /// - Parameters: + /// - offset: Signed offset. + /// - whence: `SEEK_SET`, `SEEK_CUR`, or `SEEK_END`. + func seek(offset: Int64, whence: SeekOrigin = .start) throws(AndroidFileManagerError) -> Int64 { + let result = AAsset_seek64(pointer, offset, whence.rawValue) + guard result >= 0 else { + throw .seekAsset(result) + } + return result + } + + /// Returns a file descriptor and byte range when available. + func openFileDescriptor() -> (fd: Int32, start: Int64, length: Int64)? { + var start: Int64 = 0 + var length: Int64 = 0 + let fd = AAsset_openFileDescriptor64(pointer, &start, &length) + guard fd >= 0 else { + return nil + } + return (fd, start, length) + } + + /// Returns an in-memory buffer, if this asset exposes one. + func withUnsafeBufferPointer( + _ body: (UnsafeRawBufferPointer) throws -> T + ) rethrows -> T? { + guard let baseAddress = AAsset_getBuffer(pointer) else { + return nil + } + let count = Int(max(length, 0)) + let buffer = UnsafeRawBufferPointer(start: baseAddress, count: count) + return try body(buffer) + } +} From 8eb4304b5609f1cafe09e15307972111b6180239 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:29:47 -0400 Subject: [PATCH 02/58] Add `AssetManager` --- .../AndroidAssetManager/AssetManager.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Sources/AndroidAssetManager/AssetManager.swift diff --git a/Sources/AndroidAssetManager/AssetManager.swift b/Sources/AndroidAssetManager/AssetManager.swift new file mode 100644 index 0000000..1809b24 --- /dev/null +++ b/Sources/AndroidAssetManager/AssetManager.swift @@ -0,0 +1,55 @@ +// +// AssetManager.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +/// Wrapper around Android `AAssetManager`. +public struct AssetManager: @unchecked Sendable { + + internal let pointer: OpaquePointer + + /// Creates a manager from an existing native pointer. + public init(_ pointer: OpaquePointer) { + self.pointer = pointer + } +} + +// MARK: - Methods + +public extension AssetManager { + + /// Opens an asset by path. + /// + /// - Parameters: + /// - path: Relative path under the APK `assets/` directory. + /// - mode: Access hint for Android's asset backend. + func open(_ path: String, mode: AssetMode = .streaming) throws(AndroidFileManagerError) -> Asset { + guard let pointer = path.withCString({ + AAssetManager_open(pointer, $0, mode.rawValue) + }) else { + throw .openAsset(path) + } + return Asset(pointer) + } +} + +// MARK: - Supporting Types + +public extension AssetManager { + + /// `AAssetManager_open` mode flags. + enum AssetMode: Int32, Sendable { + case unknown = 0 + case random = 1 + case streaming = 2 + case buffer = 3 + } +} + From 4395c5ba0da6d447c7008d712a68349ad8ee2f24 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:30:44 -0400 Subject: [PATCH 03/58] Add `Configuration` --- .../AndroidAssetManager/Configuration.swift | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Sources/AndroidAssetManager/Configuration.swift diff --git a/Sources/AndroidAssetManager/Configuration.swift b/Sources/AndroidAssetManager/Configuration.swift new file mode 100644 index 0000000..90771ef --- /dev/null +++ b/Sources/AndroidAssetManager/Configuration.swift @@ -0,0 +1,123 @@ +// +// Configuration.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +/// Wrapper around Android NDK `AConfiguration`. +public struct Configuration: ~Copyable { + + internal let pointer: OpaquePointer + + internal init(pointer: OpaquePointer) { + self.pointer = pointer + } + + deinit { + AConfiguration_delete(pointer) + } +} + +// MARK: - Initialization + +public extension Configuration { + + /// Creates a new, empty configuration object. + init() throws(AndroidFileManagerError) { + guard let pointer = AConfiguration_new() else { + throw .invalidConfiguration + } + self.init(pointer: pointer) + } + + /// Creates a configuration populated from the current asset manager state. + init(assetManager: borrowing AssetManager) throws(AndroidFileManagerError) { + try self.init() + AConfiguration_fromAssetManager(pointer, assetManager.pointer) + } +} + +// MARK: - Methods + +public extension Configuration { + + /// Copies all values from another configuration. + func copy(from other: borrowing Configuration) { + AConfiguration_copy(pointer, other.pointer) + } + + /// Returns bitmask differences between two configurations. + func diff(_ other: borrowing Configuration) -> Int32 { + AConfiguration_diff(pointer, other.pointer) + } + + /// Returns `true` when this configuration matches the requested one. + func matches(_ requested: borrowing Configuration) -> Bool { + AConfiguration_match(pointer, requested.pointer) != 0 + } + + /// Returns `true` if this configuration is a better match than `base`. + func isBetter(than base: borrowing Configuration, requested: borrowing Configuration) -> Bool { + AConfiguration_isBetterThan(base.pointer, pointer, requested.pointer) != 0 + } +} + +// MARK: - Properties + +public extension Configuration { + + var mobileCountryCode: Int32 { AConfiguration_getMcc(pointer) } + var mobileNetworkCode: Int32 { AConfiguration_getMnc(pointer) } + var orientation: Int32 { AConfiguration_getOrientation(pointer) } + var touchscreen: Int32 { AConfiguration_getTouchscreen(pointer) } + var density: Int32 { AConfiguration_getDensity(pointer) } + var keyboard: Int32 { AConfiguration_getKeyboard(pointer) } + var navigation: Int32 { AConfiguration_getNavigation(pointer) } + var keysHidden: Int32 { AConfiguration_getKeysHidden(pointer) } + var navHidden: Int32 { AConfiguration_getNavHidden(pointer) } + var sdkVersion: Int32 { AConfiguration_getSdkVersion(pointer) } + var screenSize: Int32 { AConfiguration_getScreenSize(pointer) } + var screenLong: Int32 { AConfiguration_getScreenLong(pointer) } + var uiModeType: Int32 { AConfiguration_getUiModeType(pointer) } + var uiModeNight: Int32 { AConfiguration_getUiModeNight(pointer) } + var screenWidthDp: Int32 { AConfiguration_getScreenWidthDp(pointer) } + var screenHeightDp: Int32 { AConfiguration_getScreenHeightDp(pointer) } + var smallestScreenWidthDp: Int32 { AConfiguration_getSmallestScreenWidthDp(pointer) } + var layoutDirection: Int32 { AConfiguration_getLayoutDirection(pointer) } + + /// ISO 639-1 language code when available. + var languageCode: String? { + var out = [CChar](repeating: 0, count: 2) + AConfiguration_getLanguage(pointer, &out) + return decodeCode(out) + } + + /// ISO 3166-1 alpha-2 region code when available. + var countryCode: String? { + var out = [CChar](repeating: 0, count: 2) + AConfiguration_getCountry(pointer, &out) + return decodeCode(out) + } +} + +// MARK: - Private + +private extension Configuration { + + func decodeCode(_ raw: [CChar]) -> String? { + guard raw.count >= 2 else { return nil } + let b0 = UInt8(bitPattern: raw[0]) + let b1 = UInt8(bitPattern: raw[1]) + guard b0 != 0 || b1 != 0 else { + return nil + } + let bytes = b1 == 0 ? [b0] : [b0, b1] + return String(decoding: bytes, as: UTF8.self) + } +} From 8625bd1da1b79f81576d11c05c35596768e90e0d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:30:51 -0400 Subject: [PATCH 04/58] Add `AndroidFileManagerError` --- Sources/AndroidAssetManager/Error.swift | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Sources/AndroidAssetManager/Error.swift diff --git a/Sources/AndroidAssetManager/Error.swift b/Sources/AndroidAssetManager/Error.swift new file mode 100644 index 0000000..261ba2e --- /dev/null +++ b/Sources/AndroidAssetManager/Error.swift @@ -0,0 +1,31 @@ +// +// Error.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +/// Android file manager error. +public enum AndroidFileManagerError: Swift.Error, Equatable, Sendable { + + /// Unable to initialize an `AConfiguration` instance. + case invalidConfiguration + + /// Unable to initialize an `AStorageManager` instance. + case invalidStorageManager + + /// Unable to open asset at the specified path. + case openAsset(String) + + /// Error reading asset bytes (result code). + case readAsset(Int32) + + /// Error seeking within asset (result code). + case seekAsset(Int64) + + /// Error mounting OBB file (result code). + case mountObb(Int32) + + /// Error unmounting OBB file (result code). + case unmountObb(Int32) +} From 27f87b4c55c94d0672f3aa21d2a889cf263f3abf Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:30:59 -0400 Subject: [PATCH 05/58] Add `StorageManager` --- .../AndroidAssetManager/StorageManager.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 Sources/AndroidAssetManager/StorageManager.swift diff --git a/Sources/AndroidAssetManager/StorageManager.swift b/Sources/AndroidAssetManager/StorageManager.swift new file mode 100644 index 0000000..c312637 --- /dev/null +++ b/Sources/AndroidAssetManager/StorageManager.swift @@ -0,0 +1,80 @@ +// +// StorageManager.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +/// Wrapper around Android `AStorageManager`. +public struct StorageManager: ~Copyable { + + internal let pointer: OpaquePointer + + internal init(pointer: OpaquePointer) { + self.pointer = pointer + } + + deinit { + AStorageManager_delete(pointer) + } +} + +// MARK: - Initialization + +public extension StorageManager { + + /// Creates an `AStorageManager` instance. + init() throws(AndroidFileManagerError) { + guard let pointer = AStorageManager_new() else { + throw .invalidStorageManager + } + self.init(pointer: pointer) + } +} + +// MARK: - OBB Methods + +public extension StorageManager { + + /// Asks Android to mount an OBB container. + func mountObb(path: String, key: String? = nil) { + path.withCString { pathCString in + if let key { + key.withCString { keyCString in + AStorageManager_mountObb(pointer, pathCString, keyCString, nil, nil) + } + } else { + AStorageManager_mountObb(pointer, pathCString, nil, nil, nil) + } + } + } + + /// Asks Android to unmount an OBB container. + func unmountObb(path: String, force: Bool = false) { + path.withCString { + AStorageManager_unmountObb(pointer, $0, force ? 1 : 0, nil, nil) + } + } + + /// Returns whether the OBB at `path` is mounted. + func isObbMounted(path: String) -> Bool { + path.withCString { rawPath in + AStorageManager_isObbMounted(pointer, rawPath) != 0 + } + } + + /// Returns the mounted OBB path for a raw OBB path. + func mountedObbPath(for path: String) -> String? { + path.withCString { rawPath in + guard let cString = AStorageManager_getMountedObbPath(pointer, rawPath) else { + return nil + } + return String(cString: cString) + } + } +} From 5bba823786c26e1642a6093ce2e88ec9f02aea19 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:31:12 -0400 Subject: [PATCH 06/58] Add stubs --- Sources/AndroidAssetManager/Syscalls.swift | 150 +++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 Sources/AndroidAssetManager/Syscalls.swift diff --git a/Sources/AndroidAssetManager/Syscalls.swift b/Sources/AndroidAssetManager/Syscalls.swift new file mode 100644 index 0000000..b50c94e --- /dev/null +++ b/Sources/AndroidAssetManager/Syscalls.swift @@ -0,0 +1,150 @@ +// +// Syscalls.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +#if !os(Android) + +func stub() -> Never { + fatalError("Not running on Android") +} + +// MARK: - AConfiguration + +func AConfiguration_new() -> OpaquePointer? { stub() } + +func AConfiguration_delete(_ config: OpaquePointer) { stub() } + +func AConfiguration_fromAssetManager(_ out: OpaquePointer, _ am: OpaquePointer) { stub() } + +func AConfiguration_copy(_ dest: OpaquePointer, _ src: OpaquePointer) { stub() } + +func AConfiguration_diff(_ config1: OpaquePointer, _ config2: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_match(_ base: OpaquePointer, _ requested: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_isBetterThan( + _ base: OpaquePointer, + _ test: OpaquePointer, + _ requested: OpaquePointer +) -> Int32 { stub() } + +func AConfiguration_getMcc(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getMnc(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getLanguage(_ config: OpaquePointer, _ outLanguage: UnsafeMutablePointer?) { stub() } + +func AConfiguration_getCountry(_ config: OpaquePointer, _ outCountry: UnsafeMutablePointer?) { stub() } + +func AConfiguration_getOrientation(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getTouchscreen(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getDensity(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getKeyboard(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getNavigation(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getKeysHidden(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getNavHidden(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getSdkVersion(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getScreenSize(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getScreenLong(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getUiModeType(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getUiModeNight(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getScreenWidthDp(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getScreenHeightDp(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getSmallestScreenWidthDp(_ config: OpaquePointer) -> Int32 { stub() } + +func AConfiguration_getLayoutDirection(_ config: OpaquePointer) -> Int32 { stub() } + +// MARK: - AAssetManager + +func AAssetManager_open( + _ manager: OpaquePointer, + _ fileName: UnsafePointer?, + _ mode: Int32 +) -> OpaquePointer? { stub() } + +// MARK: - AAsset + +func AAsset_close(_ asset: OpaquePointer) { stub() } + +func AAsset_read( + _ asset: OpaquePointer, + _ buf: UnsafeMutableRawPointer?, + _ count: Int +) -> Int32 { stub() } + +func AAsset_seek64( + _ asset: OpaquePointer, + _ offset: Int64, + _ whence: Int32 +) -> Int64 { stub() } + +func AAsset_getLength64(_ asset: OpaquePointer) -> Int64 { stub() } + +func AAsset_getRemainingLength64(_ asset: OpaquePointer) -> Int64 { stub() } + +func AAsset_getBuffer(_ asset: OpaquePointer) -> UnsafeRawPointer? { stub() } + +func AAsset_isAllocated(_ asset: OpaquePointer) -> Int32 { stub() } + +func AAsset_openFileDescriptor64( + _ asset: OpaquePointer, + _ outStart: UnsafeMutablePointer?, + _ outLength: UnsafeMutablePointer? +) -> Int32 { stub() } + +// MARK: - AStorageManager + +func AStorageManager_new() -> OpaquePointer? { stub() } + +func AStorageManager_delete(_ manager: OpaquePointer) { stub() } + +func AStorageManager_mountObb( + _ manager: OpaquePointer, + _ filename: UnsafePointer?, + _ key: UnsafePointer?, + _ callback: UnsafeMutableRawPointer?, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AStorageManager_unmountObb( + _ manager: OpaquePointer, + _ filename: UnsafePointer?, + _ force: Int32, + _ callback: UnsafeMutableRawPointer?, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AStorageManager_isObbMounted( + _ manager: OpaquePointer, + _ filename: UnsafePointer? +) -> Int32 { stub() } + +func AStorageManager_getMountedObbPath( + _ manager: OpaquePointer, + _ filename: UnsafePointer? +) -> UnsafePointer? { stub() } + +#endif From 96275225f1c7026382718525cfb7231172d23f2e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:36:32 -0400 Subject: [PATCH 07/58] Add `AssetDirectory` --- .../AndroidAssetManager/AssetDirectory.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Sources/AndroidAssetManager/AssetDirectory.swift diff --git a/Sources/AndroidAssetManager/AssetDirectory.swift b/Sources/AndroidAssetManager/AssetDirectory.swift new file mode 100644 index 0000000..5417544 --- /dev/null +++ b/Sources/AndroidAssetManager/AssetDirectory.swift @@ -0,0 +1,45 @@ +// +// AssetDirectory.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +/// A handle to an `AAssetDir`. +/// +/// Asset directory values own their pointer and close it during deinitialization. +public struct AssetDirectory: ~Copyable { + + internal let pointer: OpaquePointer + + internal init(_ pointer: OpaquePointer) { + self.pointer = pointer + } + + deinit { + AAssetDir_close(pointer) + } +} + +// MARK: - Methods + +public extension AssetDirectory { + + /// Returns the file name of the next asset in the directory, or `nil` when exhausted. + mutating func nextFileName() -> String? { + guard let cString = AAssetDir_getNextFileName(pointer) else { + return nil + } + return String(cString: cString) + } + + /// Resets the iteration to the beginning of the directory. + mutating func rewind() { + AAssetDir_rewind(pointer) + } +} From 9ee69d60a890ca433e5325979dd63db7f6547f4c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:36:48 -0400 Subject: [PATCH 08/58] Add `AssetManager.openDirectory()` --- Sources/AndroidAssetManager/AssetManager.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/AndroidAssetManager/AssetManager.swift b/Sources/AndroidAssetManager/AssetManager.swift index 1809b24..bb17436 100644 --- a/Sources/AndroidAssetManager/AssetManager.swift +++ b/Sources/AndroidAssetManager/AssetManager.swift @@ -38,6 +38,18 @@ public extension AssetManager { } return Asset(pointer) } + + /// Opens a directory for iteration over its asset file names. + /// + /// - Parameter path: Relative path under the APK `assets/` directory. Pass `""` for the root. + func openDirectory(_ path: String) throws(AndroidFileManagerError) -> AssetDirectory { + guard let pointer = path.withCString({ + AAssetManager_openDir(pointer, $0) + }) else { + throw .openAssetDirectory(path) + } + return AssetDirectory(pointer) + } } // MARK: - Supporting Types From 0188f619e3884ee8bb0fb6d7ae9210161323d589 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:36:54 -0400 Subject: [PATCH 09/58] Add stubs --- Sources/AndroidAssetManager/Syscalls.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/AndroidAssetManager/Syscalls.swift b/Sources/AndroidAssetManager/Syscalls.swift index b50c94e..b9f66b9 100644 --- a/Sources/AndroidAssetManager/Syscalls.swift +++ b/Sources/AndroidAssetManager/Syscalls.swift @@ -85,6 +85,19 @@ func AAssetManager_open( _ mode: Int32 ) -> OpaquePointer? { stub() } +func AAssetManager_openDir( + _ manager: OpaquePointer, + _ dirName: UnsafePointer? +) -> OpaquePointer? { stub() } + +// MARK: - AAssetDir + +func AAssetDir_getNextFileName(_ assetDir: OpaquePointer) -> UnsafePointer? { stub() } + +func AAssetDir_rewind(_ assetDir: OpaquePointer) { stub() } + +func AAssetDir_close(_ assetDir: OpaquePointer) { stub() } + // MARK: - AAsset func AAsset_close(_ asset: OpaquePointer) { stub() } From 9f68dea40ac043b52c96bb7919082cba4d58c285 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:37:01 -0400 Subject: [PATCH 10/58] Update `AndroidFileManagerError` --- Sources/AndroidAssetManager/Error.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AndroidAssetManager/Error.swift b/Sources/AndroidAssetManager/Error.swift index 261ba2e..ffefe78 100644 --- a/Sources/AndroidAssetManager/Error.swift +++ b/Sources/AndroidAssetManager/Error.swift @@ -17,6 +17,9 @@ public enum AndroidFileManagerError: Swift.Error, Equatable, Sendable { /// Unable to open asset at the specified path. case openAsset(String) + /// Unable to open asset directory at the specified path. + case openAssetDirectory(String) + /// Error reading asset bytes (result code). case readAsset(Int32) From 6364963f7a60ec2c37aef751f08629b2d567d03d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:43:37 -0400 Subject: [PATCH 11/58] Add `AssetDirectory.Sequence` --- .../AndroidAssetManager/AssetDirectory.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/AndroidAssetManager/AssetDirectory.swift b/Sources/AndroidAssetManager/AssetDirectory.swift index 5417544..f6e546d 100644 --- a/Sources/AndroidAssetManager/AssetDirectory.swift +++ b/Sources/AndroidAssetManager/AssetDirectory.swift @@ -43,3 +43,33 @@ public extension AssetDirectory { AAssetDir_rewind(pointer) } } + +// MARK: - Sequence + +public extension AssetDirectory { + + /// A `Sequence` adapter over ``AssetDirectory`` for use in `for`-`in` loops. + /// + /// Iteration is single-pass; call ``rewind()`` to restart from the beginning. + final class Sequence: Swift.Sequence, IteratorProtocol, @unchecked Sendable { + + public typealias Element = String + + private var directory: AssetDirectory + + public init(_ directory: consuming AssetDirectory) { + self.directory = directory + } + + public func next() -> String? { + directory.nextFileName() + } + + public func makeIterator() -> AssetDirectory.Sequence { self } + + /// Resets iteration to the beginning of the directory. + public func rewind() { + directory.rewind() + } + } +} From 5eeafe438ebb9c0c6eb76fa9cf976bcad1443ab9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:47:26 -0400 Subject: [PATCH 12/58] Remove older `AndroidAssetManager` --- .../AndroidAssetManager.swift | 219 ------------------ 1 file changed, 219 deletions(-) delete mode 100644 Sources/AndroidAssetManager/AndroidAssetManager.swift diff --git a/Sources/AndroidAssetManager/AndroidAssetManager.swift b/Sources/AndroidAssetManager/AndroidAssetManager.swift deleted file mode 100644 index d01d791..0000000 --- a/Sources/AndroidAssetManager/AndroidAssetManager.swift +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright 2025 Skip -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -#if os(Android) -import Android -import CAndroidNDK -#endif -import SwiftJavaJNICore - -/// https://developer.android.com/ndk/reference/group/asset -//@available(macOS, unavailable) -@available(iOS, unavailable) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public final class AndroidAssetManager : @unchecked Sendable { - let assetManager: OpaquePointer // AAssetManager - typealias AssetHandle = OpaquePointer - - /// Create the asset manager from the given JNI environment with a jobject pointer to the Java AssetManager. - public init(env: UnsafeMutablePointer, peer: jobject) { - #if !os(Android) - fatalError("only implemented for Android") - #else - self.assetManager = AAssetManager_fromJava(env, peer) - #endif - } - - /// List the file names for each asset in the specific directory - public func listAssets(inDirectory directory: String) -> [String]? { - #if !os(Android) - fatalError("only implemented for Android") - #else - guard let assetDir = AAssetManager_openDir(assetManager, directory) else { return nil } - defer { AAssetDir_close(assetDir) } - - var assets: [String] = [] - while let assetName = AAssetDir_getNextFileName(assetDir) { - assets.append(String(cString: assetName)) - } - return assets - #endif - } - - /// Opens the asset at the given path with the specified mode, returning nil if the asset was not found. - public func open(from path: String, mode: AssetMode) -> Asset? { - #if !os(Android) - fatalError("only implemented for Android") - #else - guard let handle = AAssetManager_open(self.assetManager, path, mode.assetMode) else { - return nil - } - return Asset(handle: handle) - #endif - } - - /// Attempt to read the entire contents from the asset with the given name. - public func load(from path: String) -> Data? { - open(from: path, mode: .buffer)?.read() - } - - /// A handle to a given Asset for an AssetManager - public class Asset { - let handle: AssetHandle - var closed = false - - init(handle: AssetHandle) { - self.handle = handle - } - - deinit { - close() - } - - /// Close the asset, freeing all associated resources - public func close() { - if closed { return } - closed = true - #if !os(Android) - fatalError("only implemented for Android") - #else - AAsset_close(handle) - #endif - } - - /// Returns the total size of the asset data - public var length: Int64 { - assert(!closed, "asset is closed") - #if !os(Android) - fatalError("only implemented for Android") - #else - return AAsset_getLength64(handle) - #endif - } - - /// Report the total amount of asset data that can be read from the current position - public var remainingLength: Int64 { - assert(!closed, "asset is closed") - #if !os(Android) - fatalError("only implemented for Android") - #else - return AAsset_getRemainingLength64(handle) - #endif - } - - /// Returns whether this asset's internal buffer is allocated in ordinary RAM (i.e. not mmapped). - public var isAllocated: Bool { - assert(!closed, "asset is closed") - #if !os(Android) - fatalError("only implemented for Android") - #else - return AAsset_isAllocated(handle) != 0 - #endif - } - - /// Attempt to read 'count' bytes of data from the current offset. - /// - /// Returns the number of bytes read, zero on EOF, or nil on error. - public func read(size: Int? = nil) -> Data? { - assert(!closed, "asset is closed") - let len = size ?? Int(self.length) - var data = Data(count: len) - - let bytesRead: Int32 = try data.withUnsafeMutableBytes { buffer in - #if !os(Android) - fatalError("only implemented for Android") - #else - AAsset_read(handle, buffer, len) - #endif - } - - if bytesRead < 0 { - return nil - } - - if Int64(bytesRead) < length { - // Resize if we read less than expected - data = data.prefix(Int(bytesRead)) - } - - return data - } - - /// Seek to the specified offset within the asset data. - public func seek(offset: Int64, whence: AssetSeek) -> Int64 { - assert(!closed, "asset is closed") - #if !os(Android) - fatalError("only implemented for Android") - #else - return AAsset_seek64(handle, offset, whence.seekMode) - #endif - } - - /// Open a new file descriptor that can be used to read the asset data. - /// - /// Returns nil if direct fd access is not possible (for example, if the asset is compressed). - public func openFileDescriptor(offset: inout Int64, outLength: inout Int64) -> Int32? { - assert(!closed, "asset is closed") - #if !os(Android) - fatalError("only implemented for Android") - #else - let fd = AAsset_openFileDescriptor64(handle, &offset, &outLength) - if fd < 0 { return nil } - return fd - #endif - } - } - - /// The mode for opening an asset. - public enum AssetMode { - case buffer - case streaming - case random - - var assetMode: Int32 { - #if !os(Android) - fatalError("only implemented for Android") - #else - switch self { - case .buffer: - return Int32(AASSET_MODE_BUFFER) - case .streaming: - return Int32(AASSET_MODE_STREAMING) - case .random: - return Int32(AASSET_MODE_RANDOM) - } - #endif - } - } - - public enum AssetSeek { - /// If whence is `SEEK_SET`, the offset is set to offset bytes - case set - /// If whence is `SEEK_CUR`, the offset is set to its current location plus offset bytes - case cur - /// If whence is `SEEK_END`, the offset is set to the size of the file plus offset bytes - case end - /// If whence is `SEEK_HOLE`, the offset is set to the start of the next hole greater than or equal to the supplied offset. The definition of a hole is provided below. - case hole - /// If whence is `SEEK_DATA`, the offset is set to the start of the next non-hole file region greater than or equal to the supplied offset - case data - - var seekMode: Int32 { - #if !os(Android) - fatalError("only implemented for Android") - #else - switch self { - case .set: return Int32(SEEK_SET) - case .cur: return Int32(SEEK_CUR) - case .end: return Int32(SEEK_END) - case .hole: return Int32(SEEK_HOLE) - case .data: return Int32(SEEK_DATA) - } - #endif - } - } -} From bbf73011d59d85f4278f9e43074ab778d0dd6940 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:56:05 -0400 Subject: [PATCH 13/58] Rename `AssetDirectory.next()` --- Sources/AndroidAssetManager/AssetDirectory.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AndroidAssetManager/AssetDirectory.swift b/Sources/AndroidAssetManager/AssetDirectory.swift index f6e546d..2c3f6f2 100644 --- a/Sources/AndroidAssetManager/AssetDirectory.swift +++ b/Sources/AndroidAssetManager/AssetDirectory.swift @@ -31,7 +31,7 @@ public struct AssetDirectory: ~Copyable { public extension AssetDirectory { /// Returns the file name of the next asset in the directory, or `nil` when exhausted. - mutating func nextFileName() -> String? { + mutating func next() -> String? { guard let cString = AAssetDir_getNextFileName(pointer) else { return nil } @@ -62,7 +62,7 @@ public extension AssetDirectory { } public func next() -> String? { - directory.nextFileName() + directory.next() } public func makeIterator() -> AssetDirectory.Sequence { self } From 9fa6129b1b9e35580c42810d44ee7f5b8f9c8e3c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:58:24 -0400 Subject: [PATCH 14/58] Upgrade to Swift 6 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index dfa354d..bee3efa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 import PackageDescription import class Foundation.FileManager import class Foundation.ProcessInfo From a9949d466cd8b78105f17fb26f06c90cbbf1be16 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:58:48 -0400 Subject: [PATCH 15/58] Update `AndroidChoreographer` for Swift 6 --- .../AndroidChoreographer.swift | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/Sources/AndroidChoreographer/AndroidChoreographer.swift b/Sources/AndroidChoreographer/AndroidChoreographer.swift index d80e1c2..e5598a1 100644 --- a/Sources/AndroidChoreographer/AndroidChoreographer.swift +++ b/Sources/AndroidChoreographer/AndroidChoreographer.swift @@ -3,53 +3,48 @@ import Android import CAndroidNDK #endif -import AndroidLogging -import CoreFoundation -//let logger = Logger(subsystem: "swift.android.native", category: "AndroidChoreographer") - -/// https://developer.android.com/ndk/reference/group/choreographer -@available(macOS, unavailable) +/// Choreographer coordinates the timing of frame rendering. +/// +/// This is the C version of the android.view.Choreographer object in Java. If you do not use Choreographer to pace your render loop, you may render too quickly for the display, increasing latency between frame submission and presentation. +/// +/// Input events are guaranteed to be processed before the frame callback is called, and will not be run concurrently. Input and sensor events should not be handled in the Choregrapher callback. +/// +/// The frame callback is also the appropriate place to run any per-frame state update logic. For example, in a game, the frame callback should be responsible for updating things like physics, AI, game state, and rendering the frame. Input and sensors should be handled separately via callbacks registered with AInputQueue and ASensorManager. +/// +/// [See Also](https://developer.android.com/ndk/reference/group/choreographer) +//@available(macOS, unavailable) @available(iOS, unavailable) @available(tvOS, unavailable) @available(watchOS, unavailable) -public final class AndroidChoreographer : @unchecked Sendable { - private let _choreographer: OpaquePointer +public struct AndroidChoreographer : @unchecked Sendable { + + private let pointer: OpaquePointer /// Get the AChoreographer instance for the main thread. /// /// Must be initialized at startup time with `setupMainChoreographer()` - public private(set) static var main: AndroidChoreographer! - + public nonisolated(unsafe) private(set) static var main: AndroidChoreographer! + /// Get the AChoreographer instance for the current thread. /// /// This must be called on an ALooper thread. public static var current: AndroidChoreographer { - #if !os(Android) - fatalError("only implemented for Android") - #else - AndroidChoreographer(choreographer: AChoreographer_getInstance()) - #endif + AndroidChoreographer(pointer: AChoreographer_getInstance()!) } - init(choreographer: OpaquePointer) { - self._choreographer = choreographer + private init(pointer: OpaquePointer) { + self.pointer = pointer } /// Add a callback to the Choreographer to invoke `_dispatch_main_queue_callback_4CF` on each frame to drain the main queue public static func setupMainChoreographer() { if Self.main == nil { - //logger.info("setupMainQueue") Self.main = AndroidChoreographer.current - //enqueueMainChoreographer() } } public func postFrameCallback(_ callback: @convention(c)(Int, UnsafeMutableRawPointer?) -> ()) { - #if !os(Android) - fatalError("only implemented for Android") - #else - AChoreographer_postFrameCallback(_choreographer, callback, nil) - #endif + AChoreographer_postFrameCallback(pointer, callback, nil) } } From 872a38137cefcc2d51a1c78fb1b7fd764bb417a7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 15:58:59 -0400 Subject: [PATCH 16/58] Add stubs --- Sources/AndroidChoreographer/Constants.swift | 34 +++++ Sources/AndroidChoreographer/Syscalls.swift | 152 +++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 Sources/AndroidChoreographer/Constants.swift create mode 100644 Sources/AndroidChoreographer/Syscalls.swift diff --git a/Sources/AndroidChoreographer/Constants.swift b/Sources/AndroidChoreographer/Constants.swift new file mode 100644 index 0000000..225193a --- /dev/null +++ b/Sources/AndroidChoreographer/Constants.swift @@ -0,0 +1,34 @@ +// +// Constants.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 7/6/25. +// + +#if !os(Android) +import CoreFoundation + +typealias AVsyncId = Int64 + +typealias AChoreographer_frameCallback = @convention(c) (Int, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_frameCallback64 = @convention(c) (Int64, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_vsyncCallback = @convention(c) (UnsafePointer?, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_refreshRateCallback = @convention(c) (Int64, UnsafeMutableRawPointer?) -> Void + +var ALOOPER_PREPARE_ALLOW_NON_CALLBACKS: Int { stub() } + +var ALOOPER_EVENT_INPUT: Int { stub() } +var ALOOPER_EVENT_OUTPUT: Int { stub() } +var ALOOPER_EVENT_ERROR: Int { stub() } +var ALOOPER_EVENT_HANGUP: Int { stub() } +var ALOOPER_EVENT_INVALID: Int { stub() } + +var ALOOPER_POLL_WAKE: Int { stub() } +var ALOOPER_POLL_CALLBACK: Int { stub() } +var ALOOPER_POLL_TIMEOUT: Int { stub() } +var ALOOPER_POLL_ERROR: Int { stub() } + +// renamed on Darwin +var kCFRunLoopDefaultMode: CFRunLoopMode { .defaultMode } + +#endif diff --git a/Sources/AndroidChoreographer/Syscalls.swift b/Sources/AndroidChoreographer/Syscalls.swift new file mode 100644 index 0000000..80d6a90 --- /dev/null +++ b/Sources/AndroidChoreographer/Syscalls.swift @@ -0,0 +1,152 @@ +// +// Syscalls.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 7/6/25. +// + + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Gblic +#endif + +import Dispatch + +// MARK: - Dispatch + +// https://github.com/apple-oss-distributions/libdispatch/blob/bd82a60ee6a73b4eca50af028b48643d51aaf1ea/src/queue.c#L8237 +// https://forums.swift.org/t/main-dispatch-queue-in-linux-sdl-app/31708/3 +@_silgen_name("_dispatch_main_queue_callback_4CF") +func _dispatch_main_queue_callback_4CF() + +@_silgen_name("_dispatch_get_main_queue_port_4CF") +func _dispatch_get_main_queue_port_4CF() -> Int32 + +#if !os(Android) + +func stub() -> Never { + fatalError("Not running on Android JVM") +} + +// MARK: - Looper + +func ALooper_forThread() -> OpaquePointer? { stub() } + +func ALooper_prepare(_ opts: Int32) -> OpaquePointer? { stub() } + +func ALooper_acquire(_ looper: OpaquePointer) { stub() } + +func ALooper_release(_ looper: OpaquePointer) { stub() } + +func ALooper_wake(_ looper: OpaquePointer) { stub() } + +func ALooper_pollOnce( + _ timeoutMillis: Int32, + _ outFd: UnsafeMutablePointer?, + _ outEvents: UnsafeMutablePointer?, + _ outData: UnsafeMutablePointer? +) -> Int32 { stub() } + +func ALooper_pollAll( + _ timeoutMillis: Int32, + _ outFd: UnsafeMutablePointer?, + _ outEvents: UnsafeMutablePointer?, + _ outData: UnsafeMutablePointer? +) -> Int32 { stub() } + +public typealias ALooper_callbackFunc = @convention(c) ( + Int32, Int32, UnsafeMutableRawPointer? +) -> Int32 + +func ALooper_addFd( + _ looper: OpaquePointer, + _ fd: Int32, + _ ident: Int32, + _ events: Int32, + _ callback: ALooper_callbackFunc?, + _ data: UnsafeMutableRawPointer? +) -> Int32 { stub() } + +func ALooper_removeFd( + _ looper: OpaquePointer, + _ fd: Int32 +) -> Int32 { stub() } + +// MARK: - Choreographer + +func AChoreographer_getInstance() -> OpaquePointer? { stub() } + +func AChoreographer_postFrameCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_postFrameCallbackDelayed( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback, + _ data: UnsafeMutableRawPointer?, + _ delayMillis: Int +) { stub() } + +func AChoreographer_postFrameCallback64( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback64, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_postFrameCallbackDelayed64( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback64, + _ data: UnsafeMutableRawPointer?, + _ delayMillis: UInt32 +) { stub() } + +func AChoreographer_postVsyncCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_vsyncCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_registerRefreshRateCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_refreshRateCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_unregisterRefreshRateCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_refreshRateCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographerFrameCallbackData_getFrameTimeNanos( + _ data: OpaquePointer +) -> Int64 { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelinesLength( + _ data: OpaquePointer +) -> Int { stub() } + +func AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex( + _ data: OpaquePointer +) -> Int { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineVsyncId( + _ data: OpaquePointer, + _ index: Int +) -> AVsyncId { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos( + _ data: OpaquePointer, + _ index: Int +) -> Int64 { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos( + _ data: OpaquePointer, + _ index: Int +) -> Int64 { stub() } + +#endif From 930abdcb46383f67e2f1ccf76f4b7da1b69c1685 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:03:57 -0400 Subject: [PATCH 17/58] Add license headers --- Sources/AndroidAssetManager/Asset.swift | 13 ++++++++++--- Sources/AndroidAssetManager/AssetDirectory.swift | 13 ++++++++++--- Sources/AndroidAssetManager/AssetManager.swift | 13 ++++++++++--- Sources/AndroidAssetManager/Configuration.swift | 13 ++++++++++--- Sources/AndroidAssetManager/Error.swift | 13 ++++++++++--- Sources/AndroidAssetManager/StorageManager.swift | 13 ++++++++++--- Sources/AndroidAssetManager/Syscalls.swift | 13 ++++++++++--- 7 files changed, 70 insertions(+), 21 deletions(-) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index 2faa846..68df0cc 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Asset.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if os(Android) import Android diff --git a/Sources/AndroidAssetManager/AssetDirectory.swift b/Sources/AndroidAssetManager/AssetDirectory.swift index 2c3f6f2..266f209 100644 --- a/Sources/AndroidAssetManager/AssetDirectory.swift +++ b/Sources/AndroidAssetManager/AssetDirectory.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// AssetDirectory.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if os(Android) import Android diff --git a/Sources/AndroidAssetManager/AssetManager.swift b/Sources/AndroidAssetManager/AssetManager.swift index bb17436..67cb0a0 100644 --- a/Sources/AndroidAssetManager/AssetManager.swift +++ b/Sources/AndroidAssetManager/AssetManager.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// AssetManager.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if os(Android) import Android diff --git a/Sources/AndroidAssetManager/Configuration.swift b/Sources/AndroidAssetManager/Configuration.swift index 90771ef..2c5a423 100644 --- a/Sources/AndroidAssetManager/Configuration.swift +++ b/Sources/AndroidAssetManager/Configuration.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Configuration.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if os(Android) import Android diff --git a/Sources/AndroidAssetManager/Error.swift b/Sources/AndroidAssetManager/Error.swift index ffefe78..92e79d0 100644 --- a/Sources/AndroidAssetManager/Error.swift +++ b/Sources/AndroidAssetManager/Error.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Error.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// /// Android file manager error. public enum AndroidFileManagerError: Swift.Error, Equatable, Sendable { diff --git a/Sources/AndroidAssetManager/StorageManager.swift b/Sources/AndroidAssetManager/StorageManager.swift index c312637..33b4578 100644 --- a/Sources/AndroidAssetManager/StorageManager.swift +++ b/Sources/AndroidAssetManager/StorageManager.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// StorageManager.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if os(Android) import Android diff --git a/Sources/AndroidAssetManager/Syscalls.swift b/Sources/AndroidAssetManager/Syscalls.swift index b9f66b9..c93f4ff 100644 --- a/Sources/AndroidAssetManager/Syscalls.swift +++ b/Sources/AndroidAssetManager/Syscalls.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Syscalls.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 2/27/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// #if canImport(Darwin) import Darwin From 9fbe312ca907e1d50c6271dfad6f8a0f31ab47af Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:09:11 -0400 Subject: [PATCH 18/58] Update `Asset.open()` --- Sources/AndroidAssetManager/Asset.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index 68df0cc..d0a28b4 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -16,6 +16,7 @@ import Android import CAndroidNDK #endif +import AndroidSystem /// A handle to an `AAsset`. /// @@ -107,14 +108,14 @@ public extension Asset { } /// Returns a file descriptor and byte range when available. - func openFileDescriptor() -> (fd: Int32, start: Int64, length: Int64)? { + func open() -> (fileDescriptor: FileDescriptor, start: Int64, length: Int64)? { var start: Int64 = 0 var length: Int64 = 0 let fd = AAsset_openFileDescriptor64(pointer, &start, &length) guard fd >= 0 else { return nil } - return (fd, start, length) + return (FileDescriptor(rawValue: fd), start, length) } /// Returns an in-memory buffer, if this asset exposes one. From e962252592342be032649f80adba6c6c9afa6ba9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:10:12 -0400 Subject: [PATCH 19/58] Add `Asset.withRawSpan()` --- Sources/AndroidAssetManager/Asset.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index d0a28b4..882fa38 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -129,4 +129,19 @@ public extension Asset { let buffer = UnsafeRawBufferPointer(start: baseAddress, count: count) return try body(buffer) } + + /// Calls `body` with a zero-copy ``RawSpan`` over the asset's in-memory buffer. + /// + /// Unlike ``withUnsafeBufferPointer(_:)``, the span carries compile-time lifetime + /// tracking that prevents it from escaping the closure. + /// + /// - Returns: `nil` if the asset is not backed by a contiguous memory region. + func withRawSpan( + _ body: (RawSpan) throws(E) -> T + ) throws(E) -> T? { + guard let baseAddress = AAsset_getBuffer(pointer) else { return nil } + let count = Int(max(length, 0)) + let rawBuffer = UnsafeRawBufferPointer(start: baseAddress, count: count) + return try body(rawBuffer.bytes) + } } From c34fcc151df27ddef1aaab7029d0b622d2bdefb4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:11:20 -0400 Subject: [PATCH 20/58] Update for Swift 6.2 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index b3b79ae..3f31699 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 //===----------------------------------------------------------------------===// // // This source file is part of the SwiftAndroidNative open source project From 94e963beabb675a2549ef390b1c92d21695b5253 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:11:55 -0400 Subject: [PATCH 21/58] Optimize `Asset.read()` --- Sources/AndroidAssetManager/Asset.swift | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index 882fa38..1feca5a 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -66,29 +66,29 @@ public extension Asset { /// Reads up to `maxCount` bytes from the current cursor position. func read(maxCount: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { - guard maxCount > 0 else { - return [] - } + guard maxCount > 0 else { return [] } var bytes = [UInt8](repeating: 0, count: maxCount) - let count = AAsset_read(pointer, &bytes, maxCount) - guard count >= 0 else { - throw .readAsset(count) + let count = bytes.withUnsafeMutableBytes { + AAsset_read(pointer, $0.baseAddress, maxCount) } - return Array(bytes.prefix(Int(count))) + guard count >= 0 else { throw .readAsset(count) } + bytes.removeSubrange(Int(count)...) + return bytes } /// Reads and returns all remaining bytes. func readAll(chunkSize: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { - guard chunkSize > 0 else { - return [] + guard chunkSize > 0 else { return [] } + // Fast path: asset is backed by a contiguous buffer — single copy. + if let bytes = withUnsafeBufferPointer({ Array($0) }) { + return bytes } + // Slow path: chunked reads. var output = [UInt8]() output.reserveCapacity(Int(max(remainingLength, 0))) while true { let chunk = try read(maxCount: chunkSize) - if chunk.isEmpty { - break - } + if chunk.isEmpty { break } output.append(contentsOf: chunk) } return output From cf9b17ae14e03f459adcb3a71d904f2de082568e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:21:08 -0400 Subject: [PATCH 22/58] Rename `CAndroidNDK` --- Package.swift | 6 +- .../AndroidChoreographer.swift | 2 +- Sources/AndroidLooper/AndroidLooper.swift | 2 +- Sources/AndroidNDK/include/ndk.h | 34 -------- Sources/AndroidNDK/src.c | 15 ---- .../AndroidSystem/Internals/CInterop.swift | 2 +- Sources/AndroidSystem/Internals/Exports.swift | 2 +- Sources/CAndroidNDK/dummy.c | 7 ++ Sources/CAndroidNDK/include/ndk.h | 84 +++++++++++++++++++ .../module.modulemap | 2 +- Sources/OSLog/AndroidLogging.swift | 2 +- 11 files changed, 100 insertions(+), 58 deletions(-) delete mode 100644 Sources/AndroidNDK/include/ndk.h delete mode 100644 Sources/AndroidNDK/src.c create mode 100644 Sources/CAndroidNDK/dummy.c create mode 100644 Sources/CAndroidNDK/include/ndk.h rename Sources/{AndroidNDK => CAndroidNDK}/module.modulemap (58%) diff --git a/Package.swift b/Package.swift index 3f31699..d4d9138 100644 --- a/Package.swift +++ b/Package.swift @@ -59,7 +59,7 @@ let package = Package( ], targets: [ .target( - name: "AndroidNDK", + name: "CAndroidNDK", linkerSettings: [ .linkedLibrary("android", .when(platforms: [.android])), .linkedLibrary("log", .when(platforms: [.android])), @@ -68,7 +68,7 @@ let package = Package( .target( name: "AndroidSystem", dependencies: [ - .target(name: "AndroidNDK", condition: .when(platforms: [.android])) + .target(name: "CAndroidNDK", condition: .when(platforms: [.android])) ], swiftSettings: [ .define("SYSTEM_PACKAGE_DARWIN", .when(platforms: [.macOS, .macCatalyst, .iOS, .watchOS, .tvOS, .visionOS])), @@ -83,7 +83,7 @@ let package = Package( name: "AndroidAssetManager", dependencies: [ .product(name: "SwiftJavaJNICore", package: "swift-java-jni-core"), - .target(name: "AndroidNDK", condition: .when(platforms: [.android])), + .target(name: "CAndroidNDK", condition: .when(platforms: [.android])), ]), .testTarget( name: "AndroidAssetManagerTests", diff --git a/Sources/AndroidChoreographer/AndroidChoreographer.swift b/Sources/AndroidChoreographer/AndroidChoreographer.swift index 6e994c8..0d21603 100644 --- a/Sources/AndroidChoreographer/AndroidChoreographer.swift +++ b/Sources/AndroidChoreographer/AndroidChoreographer.swift @@ -14,7 +14,7 @@ #if os(Android) import Android -import AndroidNDK +import CAndroidNDK #endif /// Choreographer coordinates the timing of frame rendering. diff --git a/Sources/AndroidLooper/AndroidLooper.swift b/Sources/AndroidLooper/AndroidLooper.swift index 3354382..a87537f 100644 --- a/Sources/AndroidLooper/AndroidLooper.swift +++ b/Sources/AndroidLooper/AndroidLooper.swift @@ -14,7 +14,7 @@ #if os(Android) import Android -import AndroidNDK +import CAndroidNDK import AndroidSystem import AndroidLogging import ConcurrencyRuntimeC diff --git a/Sources/AndroidNDK/include/ndk.h b/Sources/AndroidNDK/include/ndk.h deleted file mode 100644 index eea522f..0000000 --- a/Sources/AndroidNDK/include/ndk.h +++ /dev/null @@ -1,34 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAndroidNative open source project -// -// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#pragma once - -#include -#include -#include -#include - -// needed for AndroidSystem -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/Sources/AndroidNDK/src.c b/Sources/AndroidNDK/src.c deleted file mode 100644 index d9a99af..0000000 --- a/Sources/AndroidNDK/src.c +++ /dev/null @@ -1,15 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAndroidNative open source project -// -// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -// empty stub file for SwiftPM module support diff --git a/Sources/AndroidSystem/Internals/CInterop.swift b/Sources/AndroidSystem/Internals/CInterop.swift index 624ad3a..5dee2a6 100644 --- a/Sources/AndroidSystem/Internals/CInterop.swift +++ b/Sources/AndroidSystem/Internals/CInterop.swift @@ -32,7 +32,7 @@ import Musl #elseif canImport(WASILibc) import WASILibc #elseif canImport(Android) -@_implementationOnly import AndroidNDK +@_implementationOnly import CAndroidNDK import Android #else #error("Unsupported Platform") diff --git a/Sources/AndroidSystem/Internals/Exports.swift b/Sources/AndroidSystem/Internals/Exports.swift index 629f648..0037edb 100644 --- a/Sources/AndroidSystem/Internals/Exports.swift +++ b/Sources/AndroidSystem/Internals/Exports.swift @@ -37,7 +37,7 @@ import Musl #elseif canImport(WASILibc) import WASILibc #elseif canImport(Android) -@_implementationOnly import AndroidNDK +@_implementationOnly import CAndroidNDK import Android #else #error("Unsupported Platform") diff --git a/Sources/CAndroidNDK/dummy.c b/Sources/CAndroidNDK/dummy.c new file mode 100644 index 0000000..3c706df --- /dev/null +++ b/Sources/CAndroidNDK/dummy.c @@ -0,0 +1,7 @@ +#ifdef __ANDROID__ + +#include +#include +#include + +#endif diff --git a/Sources/CAndroidNDK/include/ndk.h b/Sources/CAndroidNDK/include/ndk.h new file mode 100644 index 0000000..d6789d7 --- /dev/null +++ b/Sources/CAndroidNDK/include/ndk.h @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#pragma once + +// NDK headers +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Bionic +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/Sources/AndroidNDK/module.modulemap b/Sources/CAndroidNDK/module.modulemap similarity index 58% rename from Sources/AndroidNDK/module.modulemap rename to Sources/CAndroidNDK/module.modulemap index 4dfa1ea..2ebf899 100644 --- a/Sources/AndroidNDK/module.modulemap +++ b/Sources/CAndroidNDK/module.modulemap @@ -1,4 +1,4 @@ -module AndroidNDK [system] { +module CAndroidNDK [system] { link "log" header "ndk.h" export * diff --git a/Sources/OSLog/AndroidLogging.swift b/Sources/OSLog/AndroidLogging.swift index 278cf30..615c17c 100644 --- a/Sources/OSLog/AndroidLogging.swift +++ b/Sources/OSLog/AndroidLogging.swift @@ -14,7 +14,7 @@ #if os(Android) import Android -import AndroidNDK +import CAndroidNDK #endif #if canImport(os) From 84bbfb9e1c4a2fbe95decf3860e3672f0a8d607d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:51:31 -0400 Subject: [PATCH 23/58] Add `SocketDescriptor` --- Sources/AndroidSystem/SocketDescriptor.swift | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Sources/AndroidSystem/SocketDescriptor.swift diff --git a/Sources/AndroidSystem/SocketDescriptor.swift b/Sources/AndroidSystem/SocketDescriptor.swift new file mode 100644 index 0000000..a8add0d --- /dev/null +++ b/Sources/AndroidSystem/SocketDescriptor.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Native Socket handle. +/// +/// Same as ``FileDescriptor`` on POSIX and opaque type on Windows. +public struct SocketDescriptor: RawRepresentable, Equatable, Hashable, Sendable { + + /// Native POSIX Socket handle + public typealias RawValue = FileDescriptor.RawValue + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + public let rawValue: RawValue +} From a9a5731972a9fba034a46f6c00734664d09b7b78 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:51:40 -0400 Subject: [PATCH 24/58] Add `SocketDescriptor.Event` --- Sources/AndroidSystem/SocketEvent.swift | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 Sources/AndroidSystem/SocketEvent.swift diff --git a/Sources/AndroidSystem/SocketEvent.swift b/Sources/AndroidSystem/SocketEvent.swift new file mode 100644 index 0000000..b21de0d --- /dev/null +++ b/Sources/AndroidSystem/SocketEvent.swift @@ -0,0 +1,124 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Linux) || os(Android) +public extension SocketDescriptor { + + /// File descriptor for event notification + /// + /// An "eventfd object" can be used as an event wait/notify mechanism by user-space applications, and by the kernel to notify user-space applications of events. + /// The object contains an unsigned 64-bit integer counter that is maintained by the kernel. + struct Event: RawRepresentable, Equatable, Hashable, Sendable { + + public typealias RawValue = FileDescriptor.RawValue + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + public let rawValue: RawValue + } +} + +// MARK: - Supporting Types + +public extension SocketDescriptor.Event { + + /// Flags when opening sockets. + @frozen + struct Flags: OptionSet, Hashable, Codable, Sendable { + + /// The raw C file events. + @_alwaysEmitIntoClient + public let rawValue: CInt + + /// Create a strongly-typed file events from a raw C value. + @_alwaysEmitIntoClient + public init(rawValue: CInt) { self.rawValue = rawValue } + + @_alwaysEmitIntoClient + private init(_ raw: CInt) { + self.init(rawValue: raw) + } + } +} + +public extension SocketDescriptor.Event.Flags { + + /// Set the close-on-exec (`FD_CLOEXEC`) flag on the new file descriptor. + /// + /// See the description of the `O_CLOEXEC` flag in `open(2)` for reasons why this may be useful. + @_alwaysEmitIntoClient + static var nonBlocking: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_CLOEXEC) } + + /// Set the `O_NONBLOCK` file status flag on the new open file description. + /// + /// Using this flag saves extra calls to `fcntl(2)` to achieve the same result. + @_alwaysEmitIntoClient + static var closeOnExec: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_NONBLOCK) } + + /// Provide semaphore-like semantics for reads from the new file descriptor. + @_alwaysEmitIntoClient + static var semaphore: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_SEMAPHORE) } +} + +// @available(macOS 10.16, iOS 14.0, watchOS 7.0, tvOS 14.0, *) +extension SocketDescriptor.Event.Flags: CustomStringConvertible, CustomDebugStringConvertible +{ + /// A textual representation of the open options. + @inline(never) + public var description: String { + let descriptions: [(Element, StaticString)] = [ + (.nonBlocking, ".nonBlocking"), + (.closeOnExec, ".closeOnExec"), + (.semaphore, ".semaphore"), + ] + return _buildDescription(descriptions) + } + + /// A textual representation of the open options, suitable for debugging. + public var debugDescription: String { self.description } +} + +public extension SocketDescriptor.Event { + + @frozen + struct Counter: RawRepresentable, Equatable, Hashable, Sendable { + + public typealias RawValue = UInt64 + + @_alwaysEmitIntoClient + public var rawValue: RawValue + + @_alwaysEmitIntoClient + public init(rawValue: RawValue = 0) { + self.rawValue = rawValue + } + } +} + +extension SocketDescriptor.Event.Counter: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: RawValue) { + self.init(rawValue: value) + } +} + +extension SocketDescriptor.Event.Counter: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { rawValue.description } + + public var debugDescription: String { description } +} +#endif From ed414bcf0d03861d813e0e9587782868f86aa17f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:52:02 -0400 Subject: [PATCH 25/58] Add socket event constants --- .../AndroidSystem/Internals/Constants.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/AndroidSystem/Internals/Constants.swift b/Sources/AndroidSystem/Internals/Constants.swift index 892f502..b16b2f2 100644 --- a/Sources/AndroidSystem/Internals/Constants.swift +++ b/Sources/AndroidSystem/Internals/Constants.swift @@ -627,3 +627,33 @@ internal var _SEEK_HOLE: CInt { SEEK_HOLE } @_alwaysEmitIntoClient internal var _SEEK_DATA: CInt { SEEK_DATA } #endif + +#if os(Linux) || os(Android) +@_alwaysEmitIntoClient +internal var _EFD_CLOEXEC: CInt { numericCast(EFD_CLOEXEC) } + +@_alwaysEmitIntoClient +internal var _EFD_NONBLOCK: CInt { numericCast(EFD_NONBLOCK) } + +@_alwaysEmitIntoClient +internal var _EFD_SEMAPHORE: CInt { numericCast(EFD_SEMAPHORE) } +#endif + +@_alwaysEmitIntoClient +internal var _fd_set_count: Int { +#if canImport(Darwin) + // __DARWIN_FD_SETSIZE is number of *bits*, so divide by number bits in each element to get element count + // at present this is 1024 / 32 == 32 + return Int(__DARWIN_FD_SETSIZE) / 32 +#elseif os(Linux) || os(FreeBSD) || os(Android) +#if arch(x86_64) || arch(arm64) || arch(s390x) || arch(powerpc64) || arch(powerpc64le) + return 32 +#elseif arch(i386) || arch(arm) + return 16 +#else +#error("This architecture isn't known. Add it to the 32-bit or 64-bit line.") +#endif +#elseif os(Windows) + return 32 +#endif +} From 991a2ba1e6d2430825d24246fe5150471bc1d334 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:54:58 -0400 Subject: [PATCH 26/58] Update `Looper` for Swift 6 --- Sources/AndroidLooper/AndroidLooper.swift | 321 ----------------- Sources/AndroidLooper/Looper.swift | 409 ++++++++++++++++++++++ 2 files changed, 409 insertions(+), 321 deletions(-) delete mode 100644 Sources/AndroidLooper/AndroidLooper.swift create mode 100644 Sources/AndroidLooper/Looper.swift diff --git a/Sources/AndroidLooper/AndroidLooper.swift b/Sources/AndroidLooper/AndroidLooper.swift deleted file mode 100644 index a87537f..0000000 --- a/Sources/AndroidLooper/AndroidLooper.swift +++ /dev/null @@ -1,321 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAndroidNative open source project -// -// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if os(Android) -import Android -import CAndroidNDK -import AndroidSystem -import AndroidLogging -import ConcurrencyRuntimeC -import CoreFoundation -import Dispatch - -// Much of this is adapted from https://github.com/PADL/AndroidLooper/blob/0f26e1bdb989120f5689d74ea69a0525833ecd52/Sources/AndroidLooper/ALooper.swift - -/// https://developer.android.com/ndk/reference/group/looper -public struct AndroidLooper: ~Copyable, @unchecked Sendable { - public enum LooperError: Error { - case addFdFailure - case removeFdFailure - case preparationFailure(CInt) - case pollTimeout - case pollError - } - - public struct Events: OptionSet, Sendable { - public typealias RawValue = CInt - - public let rawValue: RawValue - - public init(rawValue: RawValue) { - self.rawValue = rawValue - } - - public static let input = Events(rawValue: 1 << 0) - public static let output = Events(rawValue: 1 << 1) - public static let error = Events(rawValue: 1 << 2) - public static let hangup = Events(rawValue: 1 << 3) - public static let invalid = Events(rawValue: 1 << 4) - } - - private let _looper: OpaquePointer - - public init(wrapping looper: OpaquePointer) { - ALooper_acquire(looper) - _looper = looper - } - - deinit { - ALooper_release(_looper) - } - - // Called from applicaton entry point - public static func setupMainLooper() -> Bool { - if _mainLooper != nil { - ALooper_release(_mainLooper) - _mainLooper = nil - } - - _mainLooper = ALooper_forThread() - if _mainLooper == nil { - // this happens sometimes when running in test cases, perhaps because setup is not being called from the main thread - return false - } - ALooper_acquire(_mainLooper) - - // override the global executors to wake the main looper to drain the queue whenever something is scheduled - return AndroidMainActor.installGlobalExecutor() - } - - func deinitMainLooper() { - ALooper_release(_mainLooper) - _mainLooper = nil - } - - /// Adds a new file descriptor to be polled by the looper. - public func add(fd: FileDescriptor, ident: CInt = 0, events: Events = .input, callback: LooperCallback? = nil, data: UnsafeMutableRawPointer? = nil) throws { - if ALooper_addFd(_looper, fd.rawValue, callback != nil ? CInt(ALOOPER_POLL_CALLBACK) : ident, events.rawValue, callback, data) != 1 { - throw LooperError.addFdFailure - } - } - - /// Prepares a looper associated with the calling thread, and returns it. - public static func prepare(opts: CInt) throws -> Self { - guard let looper = ALooper_prepare(opts) else { - throw LooperError.preparationFailure(opts) - } - return AndroidLooper(wrapping: looper) - } - - /// Wakes the poll asynchronously. - public func wake() { - ALooper_wake(_looper) - } - - /// Removes a previously added file descriptor from the looper. - @discardableResult - public func remove(fd: FileDescriptor) throws -> Bool { - let ret = ALooper_removeFd(_looper, fd.rawValue) - if ret < 0 { - throw LooperError.removeFdFailure - } - return ret == 1 - } - - public struct PollResult { - let ident: CInt - let fd: CInt - let events: Events - let data: UnsafeRawPointer? - } - - /// Waits for events to be available, with optional timeout in milliseconds. - public static func pollOnce(duration: Duration? = nil) throws -> PollResult? { - var outFd: CInt = -1 - var outEvents: CInt = 0 - var outData: UnsafeMutableRawPointer? - - let timeoutMillis: CInt - if let duration = duration { - timeoutMillis = CInt(Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) * 1e-15) - } else { - timeoutMillis = 0 - } - - let err = ALooper_pollOnce(timeoutMillis, &outFd, &outEvents, &outData) - switch Int(err) { - case ALOOPER_POLL_WAKE: - fallthrough - case ALOOPER_POLL_CALLBACK: - return nil - case ALOOPER_POLL_TIMEOUT: - throw LooperError.pollTimeout - case ALOOPER_POLL_ERROR: - throw LooperError.pollError - default: - return PollResult(ident: err, fd: outFd, events: Events(rawValue: outEvents), data: outData) - } - } -} - -public typealias LooperCallback = @convention(c) (CInt, CInt, UnsafeMutableRawPointer?) -> CInt - -private var _mainLooper: OpaquePointer? = nil - -public extension AndroidLooper { - static var main: Self { - Self(wrapping: _mainLooper!) - } -} - -private func drainAExecutor(fd: CInt, events: CInt, data: UnsafeMutableRawPointer?) -> CInt { - let executor = Unmanaged.fromOpaque(data!).takeUnretainedValue() - executor.drain() - return 1 -} - -// Swift structured concurrency executor that enqueues jobs on an Android Looper. -open class AndroidLooperExecutor: SerialExecutor, @unchecked Sendable { - private let _eventFd: FileDescriptor - private let _looper: AndroidLooper - private let _queue = LockedState(initialState: [UnownedJob]()) - - /// Initialize with Android Looper - public init(looper: consuming AndroidLooper) throws { - let fd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK) - if fd < 0 { - throw Errno(rawValue: errno) - } - _eventFd = FileDescriptor(rawValue: fd) - _looper = looper - do { - try _looper.add(fd: _eventFd, callback: drainAExecutor, data: Unmanaged.passUnretained(self).toOpaque()) - } catch { - try _eventFd.close() - throw error - } - } - - deinit { - if _eventFd.rawValue != -1 { - _ = try? _looper.remove(fd: _eventFd) - try? _eventFd.close() - } - } - - /// Read number of remaining events from eventFd - private var eventsRemaining: UInt64 { - get throws { - var value = UInt64(0) - try withUnsafeMutableBytes(of: &value) { - guard try _eventFd.read(into: $0) == MemoryLayout.size else { - throw Errno.invalidArgument - } - } - - return value - } - } - - /// Increment number of remaining events on eventFd - func signal() throws { - var value = UInt64(1) - try withUnsafeBytes(of: &value) { - guard try _eventFd.write($0) == MemoryLayout.size else { - throw Errno.outOfRange - } - } - } - - /// Drain job queue - fileprivate func drain() { - if let eventsRemaining = try? eventsRemaining { - for _ in 0.. UnownedJob? { - _queue.withLock { queue in - guard !queue.isEmpty else { return nil } - return queue.removeFirst() - } - } - - /// Enqueue a single job - public func enqueue(_ job: UnownedJob) { - _queue.withLock { queue in - queue.append(job) - } - try! signal() - } - - public func asUnownedSerialExecutor() -> UnownedSerialExecutor { - UnownedSerialExecutor(ordinary: self) - } -} - -@globalActor -public final actor AndroidMainActor: GlobalActor { - static let _executor = try! AndroidLooperExecutor(looper: AndroidLooper.main) - - public static let shared = AndroidMainActor() - public static let sharedUnownedExecutor: UnownedSerialExecutor = AndroidMainActor._executor - .asUnownedSerialExecutor() - - public nonisolated var unownedExecutor: UnownedSerialExecutor { - Self.sharedUnownedExecutor - } -} - -private extension AndroidMainActor { - private static var didInstallGlobalExecutor = false - - /// Set Android event loop based executor to be the global executor - /// Note that this should be called before any of the jobs are created. - /// This installation step will be unnecessary after custom executor are - /// introduced officially, but it is part of "Future Directions": - /// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md#overriding-the-mainactor-executor - /// - /// See also [a draft proposal for custom executors](https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md#the-default-global-concurrent-executor) - static func installGlobalExecutor() -> Bool { - if didInstallGlobalExecutor { - return false - } - didInstallGlobalExecutor = true - - let looperCallback: LooperCallback = { ft, event, data in - while true { - switch CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, true) { - case CFRunLoopRunResult.handledSource: - continue // continue run loop - case CFRunLoopRunResult.finished: - return 1 // continue listening for events - case CFRunLoopRunResult.stopped: - return 0 // stop listening - case CFRunLoopRunResult.timedOut: - return 1 // continue listening for events - } - } - } - - let mainLoop = CFRunLoopGetMain() - - // https://github.com/readdle/swift-android-ndk/blob/main/Sources/CAndroidNDK/MainRunLoop.c#L71 - //__CFPort wakeUpPort = mainLoop->_wakeUpPort; - //int result = ALooper_addFd(_mainLooper, wakeUpPort, 0, CInt(ALOOPER_EVENT_INPUT), &looperCallback, nil) - //mainLoop->_perRunData->ignoreWakeUps = 0x0; - - let dispatchPort = _dispatch_get_main_queue_port_4CF() - let result = ALooper_addFd(_mainLooper, dispatchPort, 0, CInt(ALOOPER_EVENT_INPUT), looperCallback, nil) - return result == 1 // Returns 1 if the file descriptor was added or -1 if an error occurred. - } -} - -// https://github.com/apple-oss-distributions/libdispatch/blob/bd82a60ee6a73b4eca50af028b48643d51aaf1ea/src/queue.c#L8237 -// https://forums.swift.org/t/main-dispatch-queue-in-linux-sdl-app/31708/3 -@_silgen_name("_dispatch_main_queue_callback_4CF") -func _dispatch_main_queue_callback_4CF() - -@_silgen_name("_dispatch_get_main_queue_port_4CF") -func _dispatch_get_main_queue_port_4CF() -> Int32 -#endif diff --git a/Sources/AndroidLooper/Looper.swift b/Sources/AndroidLooper/Looper.swift new file mode 100644 index 0000000..b0ccb65 --- /dev/null +++ b/Sources/AndroidLooper/Looper.swift @@ -0,0 +1,409 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Android) +import Android +import CAndroidNDK +#endif +import AndroidSystem + +/** + * Looper + * + * A looper is the state tracking an event loop for a thread. + * Loopers do not define event structures or other such things; rather + * they are a lower-level facility to attach one or more discrete objects + * listening for an event. An "event" here is simply data available on + * a file descriptor: each attached object has an associated file descriptor, + * and waiting for "events" means (internally) polling on all of these file + * descriptors until one or more of them have data available. + * + * A thread can have only one `ALooper` associated with it. + */ +public struct Looper: ~Copyable { + + internal let handle: Handle + + /// Whether the handle is "owned" and we need to release on deinit. + internal let isRetained: Bool + + internal init(_ handle: Handle, retain: Bool) { + self.handle = handle + self.isRetained = retain + } + + deinit { + if isRetained { + handle.release() + } + } +} + +// MARK: - Initialization + +public extension Looper { + + /// Directly initialize from a pointer and retain the underlying object. + init(_ pointer: OpaquePointer) { + self.init(Handle(pointer), retain: true) // retains by default + } + + /// Initialize from a pointer without retaining the underlying object. + static func takeUnretained(from pointer: OpaquePointer) -> Looper { + // equivalent to + // Unmanaged.fromOpaque(pointer).takeUnretainedValue() + self.init(Handle(pointer), retain: false) + } + + /// Initialize from another instance and retain the underlying object. + init(_ other: borrowing Looper) { + self.init(other.handle, retain: true) + } + + /// Gets the looper for the current thread, if any. + /// + /// The instance is retained. + static var currentThread: Looper? { + Handle.forThread().flatMap { .init($0, retain: true) } + } + + /// Gets the looper for the current thread, if any and provides a borrowed instance to use. + /// + /// The instance is not retained and only valid for the duration of ``body``. + static func currentThread(_ body: (borrowing Looper) throws(E) -> (T)) throws(E) -> T? { + let looper = Looper.Handle + .forThread() + .flatMap{ Looper($0, retain: false) } // don't retain this instance + guard let looper else { + return nil + } + return try body(looper) + } + + /// Prepares a looper associated with the calling thread, and returns it. + /// + /// The instance is retained. + static func currentThread(options: PrepareOptions) -> Looper { + Looper(.prepare(options: options), retain: true) + } + + /// Gets the looper for the current thread, if any and provides a borrowed instance to use. + /// + /// The instance is not retained and only valid for the duration of ``body``. + static func currentThread(options: PrepareOptions, _ body: (borrowing Looper) throws(E) -> (T)) throws(E) -> T { + let looper = Looper(.prepare(options: options), retain: false) + return try body(looper) + } +} + +// MARK: - Properties + +public extension Looper { + + +} + +// MARK: - Methods + +public extension Looper { + + /// Access the underlying opaque pointer. + func withUnsafePointer(_ body: (OpaquePointer) throws(E) -> Result) throws(E) -> Result where E: Swift.Error { + try body(handle.pointer) + } + + /** + * Wakes the poll asynchronously. + * + * This method can be called on any thread. + * This method returns immediately. + */ + func wake() { + handle.wake() + } + + /** + * Removes a previously added file descriptor from the looper. + * + * When this method returns, it is safe to close the file descriptor since the looper + * will no longer have a reference to it. However, it is possible for the callback to + * already be running or for it to run one last time if the file descriptor was already + * signalled. Calling code is responsible for ensuring that this case is safely handled. + * For example, if the callback takes care of removing itself during its own execution either + * by returning 0 or by calling this method, then it can be guaranteed to not be invoked + * again at any later time unless registered anew. + * + * Returns 1 if the file descriptor was removed, 0 if none was previously registered + * or -1 if an error occurred. + * + * This method can be called on any thread. + * This method may block briefly if it needs to wake the poll. + */ + func remove(fileDescriptor: FileDescriptor) throws(AndroidLooperError) { + try handle.remove(fileDescriptor: fileDescriptor).map(fileDescriptor).get() + } +} + +// MARK: - Supporting Types + +public extension Looper { + + +} + +internal extension Looper.Handle { + + typealias Callback = @convention(c) (CInt, CInt, UnsafeMutableRawPointer?) -> CInt + + /// 1 if the file descriptor was removed, 0 if none was previously registered or -1 if an error occurred. + enum RemoveFileDescriptorResult: CInt, Sendable, CaseIterable { + + /// File descriptor was not previously registered. + case invalid = 0 + + /// File descriptor was removed. + case removed = 1 + + /// Error ocurred + case error = -1 + } + + struct PollResult: Identifiable { + public let id: CInt + public let fd: FileDescriptor + public let events: Looper.Events + public let data: UnsafeRawPointer? + } +} + +internal extension Looper.Handle.RemoveFileDescriptorResult { + + init(_ raw: RawValue) { + guard let value = Self.init(rawValue: raw) else { + assertionFailure("Invalid \(Self.self): \(raw)") + self = .error + return + } + self = value + } + + func map(_ value: FileDescriptor) -> Result { + switch self { + case .removed: + return .success(()) + case .invalid: + return .failure(.fileDescriptorNotRegistered(value)) + case .error: + return .failure(.removeFileDescriptor(value)) + } + } +} + +internal extension Looper { + + struct Handle { + + let pointer: OpaquePointer + + init(_ pointer: OpaquePointer) { + self.pointer = pointer + } + } +} + +internal extension Looper.Handle { + + /** + * Returns the looper associated with the calling thread, or NULL if + * there is not one. + */ + static func forThread() -> Looper.Handle? { + ALooper_forThread().flatMap { .init($0) } + } + + /** + * Prepares a looper associated with the calling thread, and returns it. + * If the thread already has a looper, it is returned. Otherwise, a new + * one is created, associated with the thread, and returned. + * + * The opts may be ALOOPER_PREPARE_ALLOW_NON_CALLBACKS or 0. + */ + static func prepare(options: Looper.PrepareOptions = []) -> Looper.Handle { + guard let pointer = ALooper_prepare(Int32(options.rawValue)) else { + fatalError("Unable to initialize") + } + return Looper.Handle(pointer) + } + + /** + * Acquire a reference on the given `ALooper` object. This prevents the object + * from being deleted until the reference is removed. This is only needed + * to safely hand an `ALooper` from one thread to another. + */ + func retain() { + ALooper_acquire(pointer) + } + + /** + * Remove a reference that was previously acquired with `ALooper_acquire()`. + */ + func release() { + ALooper_release(pointer) + } + + /** + * Wakes the poll asynchronously. + * + * This method can be called on any thread. + * This method returns immediately. + */ + func wake() { + ALooper_wake(pointer) + } + + /** + * Adds a new file descriptor to be polled by the looper. + * If the same file descriptor was previously added, it is replaced. + * + * "fd" is the file descriptor to be added. + * "ident" is an identifier for this event, which is returned from ALooper_pollOnce(). + * The identifier must be >= 0, or ALOOPER_POLL_CALLBACK if providing a non-NULL callback. + * "events" are the poll events to wake up on. Typically this is ALOOPER_EVENT_INPUT. + * "callback" is the function to call when there is an event on the file descriptor. + * "data" is a private data pointer to supply to the callback. + * + * There are two main uses of this function: + * + * (1) If "callback" is non-NULL, then this function will be called when there is + * data on the file descriptor. It should execute any events it has pending, + * appropriately reading from the file descriptor. The 'ident' is ignored in this case. + * + * (2) If "callback" is NULL, the 'ident' will be returned by ALooper_pollOnce + * when its file descriptor has data available, requiring the caller to take + * care of processing it. + * + * Returns 1 if the file descriptor was added or -1 if an error occurred. + * + * This method can be called on any thread. + * This method may block briefly if it needs to wake the poll. + */ + func add( + fileDescriptor: FileDescriptor, + id: CInt = CInt(ALOOPER_POLL_CALLBACK), + events: Looper.Events = .input, + callback: Callback? = nil, + data: UnsafeMutableRawPointer? = nil + ) -> Result { + let id = callback != nil ? CInt(ALOOPER_POLL_CALLBACK) : id + let result = ALooper_addFd( + pointer, fileDescriptor.rawValue, + id, + Int32(events.rawValue), + callback, + data + ) + guard result == 1 else { + return .failure(.addFileDescriptor(fileDescriptor)) + } + return .success(()) + } + + /** + * Removes a previously added file descriptor from the looper. + * + * When this method returns, it is safe to close the file descriptor since the looper + * will no longer have a reference to it. However, it is possible for the callback to + * already be running or for it to run one last time if the file descriptor was already + * signalled. Calling code is responsible for ensuring that this case is safely handled. + * For example, if the callback takes care of removing itself during its own execution either + * by returning 0 or by calling this method, then it can be guaranteed to not be invoked + * again at any later time unless registered anew. + * + * Returns 1 if the file descriptor was removed, 0 if none was previously registered + * or -1 if an error occurred. + * + * This method can be called on any thread. + * This method may block briefly if it needs to wake the poll. + */ + func remove(fileDescriptor: FileDescriptor) -> RemoveFileDescriptorResult { + let result = ALooper_removeFd(pointer, fileDescriptor.rawValue) + return .init(result) + } + + /// Waits for events to be available, with optional timeout in milliseconds. + @available(macOS 13.0, *) + func pollOnce(duration: Duration? = nil) -> Result { + pollOnce(milliseconds: duration?.milliseconds) + } + + /** + * Waits for events to be available, with optional timeout in milliseconds. + * Invokes callbacks for all file descriptors on which an event occurred. + * + * If the timeout is zero, returns immediately without blocking. + * If the timeout is negative, waits indefinitely until an event appears. + * + * Returns ALOOPER_POLL_WAKE if the poll was awoken using ALooper_wake() before + * the timeout expired and no callbacks were invoked and no other file + * descriptors were ready. **All return values may also imply + * ALOOPER_POLL_WAKE.** + * + * Returns ALOOPER_POLL_CALLBACK if one or more callbacks were invoked. The poll + * may also have been explicitly woken by ALooper_wake. + * + * Returns ALOOPER_POLL_TIMEOUT if there was no data before the given timeout + * expired. The poll may also have been explicitly woken by ALooper_wake. + * + * Returns ALOOPER_POLL_ERROR if the calling thread has no associated Looper or + * for unrecoverable internal errors. The poll may also have been explicitly + * woken by ALooper_wake. + * + * Returns a value >= 0 containing an identifier (the same identifier `ident` + * passed to ALooper_addFd()) if its file descriptor has data and it has no + * callback function (requiring the caller here to handle it). In this (and + * only this) case outFd, outEvents and outData will contain the poll events and + * data associated with the fd, otherwise they will be set to NULL. The poll may + * also have been explicitly woken by ALooper_wake. + * + * This method does not return until it has finished invoking the appropriate callbacks + * for all file descriptors that were signalled. + */ + func pollOnce(milliseconds: Double? = nil) -> Result { + var outFd: CInt = -1 + var outEvents: CInt = 0 + var outData: UnsafeMutableRawPointer? + let timeoutMillis: CInt = milliseconds.map { CInt($0) } ?? 0 + + let err = ALooper_pollOnce(timeoutMillis, &outFd, &outEvents, &outData) + switch Int(err) { + case ALOOPER_POLL_WAKE: + fallthrough + case ALOOPER_POLL_CALLBACK: + return .success(nil) + case ALOOPER_POLL_TIMEOUT: + return .failure(.pollTimeout) + case ALOOPER_POLL_ERROR: + return .failure(.pollError) + default: + return .success( + PollResult( + id: err, + fd: .init(rawValue: outFd), + events: Looper.Events(rawValue: Int(outEvents)), + data: outData + ) + ) + } + } +} From 6903c3a566ba4f917d1a29762fb1abde34b5f3dd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:55:15 -0400 Subject: [PATCH 27/58] Update `AndroidMainActor` for Swift 6 --- Sources/AndroidLooper/AndroidMainActor.swift | 143 +++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 Sources/AndroidLooper/AndroidMainActor.swift diff --git a/Sources/AndroidLooper/AndroidMainActor.swift b/Sources/AndroidLooper/AndroidMainActor.swift new file mode 100644 index 0000000..eb68d9b --- /dev/null +++ b/Sources/AndroidLooper/AndroidMainActor.swift @@ -0,0 +1,143 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +import AndroidSystem +import CoreFoundation +import Dispatch + +@available(macOS 13.0, *) +@globalActor +public actor AndroidMainActor: GlobalActor { + + public static let shared = AndroidMainActor() + + public static let sharedUnownedExecutor: UnownedSerialExecutor = { + // ensure executor is retained to avoid crash + // https://forums.swift.org/t/how-to-properly-use-custom-executor-on-global-actor/71829/4 + guard let executor = AndroidMainActor.executor else { + fatalError("Executor was never installed") + } + return executor.asUnownedSerialExecutor() + }() + + public nonisolated var unownedExecutor: UnownedSerialExecutor { + Self.sharedUnownedExecutor + } +} + +@available(macOS 13.0, *) +public extension AndroidMainActor { + + /// Setup the main looper, + /// + /// - Note: Make sure to call from main thread. + static func setupMainLooper() -> Bool { + + // release previous looper and executor + executor = nil + + // acquire looper for current thread (retained) + guard let looper = Looper.currentThread else { + // this happens sometimes when running in test cases + return false + } + + // the public API should always be retained. + assert(looper.isRetained) + + // override the global executors to wake the main looper to drain the queue whenever something is scheduled + do { + let executor = try Looper.Executor(looper: looper) + return try AndroidMainActor.installGlobalExecutor(executor) + } + catch { + return false + } + } +} + +@available(macOS 13.0, *) +extension Looper { + + /// Returns the main Looper setup with `AndroidMainActor` + static var main: Self { + guard let executor = AndroidMainActor.executor else { + fatalError("Executor was never installed") + } + return Looper(executor.looper) // return a retained instance + } +} + +@available(macOS 13.0, *) +private extension AndroidMainActor { + + nonisolated(unsafe) static var didInstallGlobalExecutor = false + + nonisolated(unsafe) static var executor: Looper.Executor? + + /// Set Android event loop based executor to be the global executor + /// Note that this should be called before any of the jobs are created. + /// This installation step will be unnecessary after custom executor are + /// introduced officially, but it is part of "Future Directions": + /// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md#overriding-the-mainactor-executor + /// + /// See also [a draft proposal for custom executors](https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md#the-default-global-concurrent-executor) + static func installGlobalExecutor( + _ executor: Looper.Executor + ) throws(AndroidLooperError) -> Bool { + if didInstallGlobalExecutor { + return false + } + didInstallGlobalExecutor = true + + let looperCallback: Looper.Handle.Callback = { ft, event, data in + while true { + switch CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, true) { + case CFRunLoopRunResult.handledSource: + continue // continue run loop + case CFRunLoopRunResult.finished: + return 1 // continue listening for events + case CFRunLoopRunResult.stopped: + return 0 // stop listening + case CFRunLoopRunResult.timedOut: + return 1 // continue listening for events + default: + break + } + } + } + + let mainLoop = CFRunLoopGetMain() // initialize main loop + let dispatchPort = _dispatch_get_main_queue_port_4CF() + let fileDescriptor = FileDescriptor(rawValue: dispatchPort) + + try executor.looper.handle.add( + fileDescriptor: fileDescriptor, + id: 0, + events: .input, + callback: looperCallback, + data: nil + ).get() + + // install executor + self.executor = executor + _ = mainLoop + return true + } +} From 9731c4ae0529e23d3d778231b6a4163ce46d85fd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:55:37 -0400 Subject: [PATCH 28/58] Add stubs --- Sources/AndroidLooper/Constants.swift | 41 +++++++ Sources/AndroidLooper/Syscalls.swift | 159 ++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 Sources/AndroidLooper/Constants.swift create mode 100644 Sources/AndroidLooper/Syscalls.swift diff --git a/Sources/AndroidLooper/Constants.swift b/Sources/AndroidLooper/Constants.swift new file mode 100644 index 0000000..36971a7 --- /dev/null +++ b/Sources/AndroidLooper/Constants.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if !os(Android) +import CoreFoundation + +typealias AVsyncId = Int64 + +typealias AChoreographer_frameCallback = @convention(c) (Int, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_frameCallback64 = @convention(c) (Int64, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_vsyncCallback = @convention(c) (UnsafePointer?, UnsafeMutableRawPointer?) -> Void +typealias AChoreographer_refreshRateCallback = @convention(c) (Int64, UnsafeMutableRawPointer?) -> Void + +var ALOOPER_PREPARE_ALLOW_NON_CALLBACKS: Int { stub() } + +var ALOOPER_EVENT_INPUT: Int { stub() } +var ALOOPER_EVENT_OUTPUT: Int { stub() } +var ALOOPER_EVENT_ERROR: Int { stub() } +var ALOOPER_EVENT_HANGUP: Int { stub() } +var ALOOPER_EVENT_INVALID: Int { stub() } + +var ALOOPER_POLL_WAKE: Int { stub() } +var ALOOPER_POLL_CALLBACK: Int { stub() } +var ALOOPER_POLL_TIMEOUT: Int { stub() } +var ALOOPER_POLL_ERROR: Int { stub() } + +// renamed on Darwin +var kCFRunLoopDefaultMode: CFRunLoopMode { .defaultMode } + +#endif diff --git a/Sources/AndroidLooper/Syscalls.swift b/Sources/AndroidLooper/Syscalls.swift new file mode 100644 index 0000000..75546ad --- /dev/null +++ b/Sources/AndroidLooper/Syscalls.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Gblic +#endif + +import Dispatch + +// MARK: - Dispatch + +// https://github.com/apple-oss-distributions/libdispatch/blob/bd82a60ee6a73b4eca50af028b48643d51aaf1ea/src/queue.c#L8237 +// https://forums.swift.org/t/main-dispatch-queue-in-linux-sdl-app/31708/3 +@_silgen_name("_dispatch_main_queue_callback_4CF") +func _dispatch_main_queue_callback_4CF() + +@_silgen_name("_dispatch_get_main_queue_port_4CF") +func _dispatch_get_main_queue_port_4CF() -> Int32 + +#if !os(Android) + +func stub() -> Never { + fatalError("Not running on Android JVM") +} + +// MARK: - Looper + +func ALooper_forThread() -> OpaquePointer? { stub() } + +func ALooper_prepare(_ opts: Int32) -> OpaquePointer? { stub() } + +func ALooper_acquire(_ looper: OpaquePointer) { stub() } + +func ALooper_release(_ looper: OpaquePointer) { stub() } + +func ALooper_wake(_ looper: OpaquePointer) { stub() } + +func ALooper_pollOnce( + _ timeoutMillis: Int32, + _ outFd: UnsafeMutablePointer?, + _ outEvents: UnsafeMutablePointer?, + _ outData: UnsafeMutablePointer? +) -> Int32 { stub() } + +func ALooper_pollAll( + _ timeoutMillis: Int32, + _ outFd: UnsafeMutablePointer?, + _ outEvents: UnsafeMutablePointer?, + _ outData: UnsafeMutablePointer? +) -> Int32 { stub() } + +public typealias ALooper_callbackFunc = @convention(c) ( + Int32, Int32, UnsafeMutableRawPointer? +) -> Int32 + +func ALooper_addFd( + _ looper: OpaquePointer, + _ fd: Int32, + _ ident: Int32, + _ events: Int32, + _ callback: ALooper_callbackFunc?, + _ data: UnsafeMutableRawPointer? +) -> Int32 { stub() } + +func ALooper_removeFd( + _ looper: OpaquePointer, + _ fd: Int32 +) -> Int32 { stub() } + +// MARK: - Choreographer + +func AChoreographer_getInstance() -> OpaquePointer? { stub() } + +func AChoreographer_postFrameCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_postFrameCallbackDelayed( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback, + _ data: UnsafeMutableRawPointer?, + _ delayMillis: Int +) { stub() } + +func AChoreographer_postFrameCallback64( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback64, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_postFrameCallbackDelayed64( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_frameCallback64, + _ data: UnsafeMutableRawPointer?, + _ delayMillis: UInt32 +) { stub() } + +func AChoreographer_postVsyncCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_vsyncCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_registerRefreshRateCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_refreshRateCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographer_unregisterRefreshRateCallback( + _ choreographer: OpaquePointer, + _ callback: @escaping AChoreographer_refreshRateCallback, + _ data: UnsafeMutableRawPointer? +) { stub() } + +func AChoreographerFrameCallbackData_getFrameTimeNanos( + _ data: OpaquePointer +) -> Int64 { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelinesLength( + _ data: OpaquePointer +) -> Int { stub() } + +func AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex( + _ data: OpaquePointer +) -> Int { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineVsyncId( + _ data: OpaquePointer, + _ index: Int +) -> AVsyncId { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos( + _ data: OpaquePointer, + _ index: Int +) -> Int64 { stub() } + +func AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos( + _ data: OpaquePointer, + _ index: Int +) -> Int64 { stub() } + +#endif From 8b6bffb0a1997c6de45df952eb2158dda2c57f81 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:55:49 -0400 Subject: [PATCH 29/58] Add `AndroidLooperError` --- Sources/AndroidLooper/Error.swift | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Sources/AndroidLooper/Error.swift diff --git a/Sources/AndroidLooper/Error.swift b/Sources/AndroidLooper/Error.swift new file mode 100644 index 0000000..035efcb --- /dev/null +++ b/Sources/AndroidLooper/Error.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AndroidSystem + +/// Android Looper Error +public enum AndroidLooperError: Swift.Error { + + /// Underlying Bionic Error + case bionic(Errno) + + case addFileDescriptor(FileDescriptor) + + /// Unable to remove the file descriptor. + case removeFileDescriptor(FileDescriptor) + + /// File Descriptor not registered + case fileDescriptorNotRegistered(FileDescriptor) + + /// Poll Timeout + case pollTimeout + + /// Poll Error + case pollError +} From cafc0da5bd154519c019d3a9b019f643ea5bd909 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:56:04 -0400 Subject: [PATCH 30/58] Add `Looper.Events` --- Sources/AndroidLooper/LooperEvents.swift | 103 +++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 Sources/AndroidLooper/LooperEvents.swift diff --git a/Sources/AndroidLooper/LooperEvents.swift b/Sources/AndroidLooper/LooperEvents.swift new file mode 100644 index 0000000..1e092f4 --- /dev/null +++ b/Sources/AndroidLooper/LooperEvents.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +public extension Looper { + + /** + * Flags for file descriptor events that a looper can monitor. + * + * These flag bits can be combined to monitor multiple events at once. + */ + struct Events: OptionSet, Sendable { + + public typealias RawValue = Int + + public var rawValue: Int + + public init(rawValue: Int) { + self.init(rawValue) + } + + private init(_ raw: Int) { + self.rawValue = raw + } + } +} + +// MARK: - Constants + +public extension Looper.Events { + + /** + * The file descriptor is available for read operations. + */ + static var input: Looper.Events { .init(ALOOPER_EVENT_INPUT) } + + /** + * The file descriptor is available for write operations. + */ + static var output: Looper.Events { .init(ALOOPER_EVENT_OUTPUT) } + + /** + * The file descriptor has encountered an error condition. + * + * The looper always sends notifications about errors; it is not necessary + * to specify this event flag in the requested event set. + */ + static var error: Looper.Events { .init(ALOOPER_EVENT_ERROR) } + + /** + * The file descriptor was hung up. + * For example, indicates that the remote end of a pipe or socket was closed. + * + * The looper always sends notifications about hangups; it is not necessary + * to specify this event flag in the requested event set. + */ + static var hangup: Looper.Events { .init(ALOOPER_EVENT_HANGUP)} + + /** + * The file descriptor is invalid. + * For example, the file descriptor was closed prematurely. + * + * The looper always sends notifications about invalid file descriptors; it is not necessary + * to specify this event flag in the requested event set. + */ + static var invalid: Looper.Events { .init(ALOOPER_EVENT_INVALID) } +} + +// MARK: - CustomStringConvertible + +extension Looper.Events: CustomStringConvertible, CustomDebugStringConvertible { + + /// A textual representation of the binder object flags. + @inline(never) + public var description: String { + let descriptions: [(Looper.Events, StaticString)] = [ + (.input, ".input"), + (.output, ".output"), + (.error, ".error"), + (.hangup, ".hangup"), + (.invalid, ".invalid") + ] + return _buildDescription(descriptions) + } + + /// A textual representation of the binder object flags, suitable for debugging. + public var debugDescription: String { self.description } +} From fa7a7bce36e2a409a67db09255febb0c7020cb8d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:56:14 -0400 Subject: [PATCH 31/58] Add `Looper.PrepareOptions` --- .../AndroidLooper/LooperPrepareOptions.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Sources/AndroidLooper/LooperPrepareOptions.swift diff --git a/Sources/AndroidLooper/LooperPrepareOptions.swift b/Sources/AndroidLooper/LooperPrepareOptions.swift new file mode 100644 index 0000000..7f56bc5 --- /dev/null +++ b/Sources/AndroidLooper/LooperPrepareOptions.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +public extension Looper { + + /// Looper Prepare Options + struct PrepareOptions: OptionSet, Sendable { + + public typealias RawValue = Int + + public var rawValue: RawValue + + public init(rawValue: RawValue) { + self.init(rawValue) + } + + private init(_ raw: RawValue) { + self.rawValue = raw + } + } +} + +// MARK: - Constants + +public extension Looper.PrepareOptions { + + /** + * This looper will accept calls to ALooper_addFd() that do not + * have a callback (that is provide NULL for the callback). In + * this case the caller of ALooper_pollOnce() or ALooper_pollAll() + * MUST check the return from these functions to discover when + * data is available on such fds and process it. + */ + static var allowNonCallbacks: Looper.PrepareOptions { .init(ALOOPER_PREPARE_ALLOW_NON_CALLBACKS) } +} + +// MARK: - CustomStringConvertible + +extension Looper.PrepareOptions: CustomStringConvertible, CustomDebugStringConvertible { + + /// A textual representation of the binder object flags. + @inline(never) + public var description: String { + let descriptions: [(Looper.PrepareOptions, StaticString)] = [ + (.allowNonCallbacks, ".allowNonCallbacks") + ] + return _buildDescription(descriptions) + } + + /// A textual representation of the binder object flags, suitable for debugging. + public var debugDescription: String { self.description } +} From 75ca1ac14add28295db17b3a91b6292bec14ba36 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:56:38 -0400 Subject: [PATCH 32/58] Add `Looper.Executor` --- Sources/AndroidLooper/SerialExecutor.swift | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 Sources/AndroidLooper/SerialExecutor.swift diff --git a/Sources/AndroidLooper/SerialExecutor.swift b/Sources/AndroidLooper/SerialExecutor.swift new file mode 100644 index 0000000..fe03904 --- /dev/null +++ b/Sources/AndroidLooper/SerialExecutor.swift @@ -0,0 +1,141 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if os(Android) +import Android +import CAndroidNDK +#endif + +import CoreFoundation +import Dispatch +import AndroidSystem + +@available(macOS 13.0, iOS 13.0, *) +public extension Looper { + + // Swift structured concurrency executor that enqueues jobs on an Android Looper. + final class Executor: SerialExecutor, @unchecked Sendable { + + #if os(Android) + let eventFd: SocketDescriptor.Event + #endif + let looper: Looper + let queue = LockedState(initialState: [UnownedJob]()) + + /// Initialize with Android Looper + internal init(looper: consuming Looper) throws(AndroidLooperError) { + #if os(Android) + let eventFd: SocketDescriptor.Event + // open fd + do { + eventFd = try SocketDescriptor.Event(0, flags: [.closeOnExec, .nonBlocking]) + } + catch { + throw .bionic(error) + } + // initialize + self.eventFd = eventFd + #endif + self.looper = looper + // Add fd to Looper + try configureLooper() + } + + deinit { + #if os(Android) + if eventFd.rawValue != -1 { + _ = try? looper.remove(fileDescriptor: .init(rawValue: eventFd.rawValue)) + try? eventFd.close() + } + #endif + } + + /// Enqueue a single job + public func enqueue(_ job: UnownedJob) { + queue.withLock { queue in + queue.append(job) + } + try! signal() + } + + public func asUnownedSerialExecutor() -> UnownedSerialExecutor { + UnownedSerialExecutor(ordinary: self) + } + } +} + +@available(macOS 13.0, iOS 13.0, *) +internal extension Looper.Executor { + + func configureLooper() throws(AndroidLooperError) { + #if os(Android) + do { + // add to looper + try looper.handle.add(fileDescriptor: .init(rawValue: eventFd.rawValue), callback: drainAExecutor, data: Unmanaged.passUnretained(self).toOpaque()).get() + } + catch { + try? eventFd.close() + throw error + } + #endif + } + + /// Read number of remaining events from eventFd + var eventsRemaining: UInt64 { + get throws { + #if os(Android) + try eventFd.read().rawValue + #else + 0 + #endif + } + } + + /// Increment number of remaining events on eventFd + func signal() throws { + #if os(Android) + try eventFd.write(1) + #endif + } + + /// Drain job queue + func drain() { + if let eventsRemaining = try? eventsRemaining { + for _ in 0.. UnownedJob? { + queue.withLock { queue in + guard !queue.isEmpty else { return nil } + return queue.removeFirst() + } + } +} + +@available(macOS 13.0, iOS 13.0, *) +private func drainAExecutor(fd: CInt, events: CInt, data: UnsafeMutableRawPointer?) -> CInt { + let executor = Unmanaged.fromOpaque(data!).takeUnretainedValue() + executor.drain() + return 1 +} From 81dcd63674ade650bd6fa4cc6cf319795ca511e4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:56:48 -0400 Subject: [PATCH 33/58] Add `Duration` extensions --- Sources/AndroidLooper/Extensions/Duration.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Sources/AndroidLooper/Extensions/Duration.swift diff --git a/Sources/AndroidLooper/Extensions/Duration.swift b/Sources/AndroidLooper/Extensions/Duration.swift new file mode 100644 index 0000000..35cfada --- /dev/null +++ b/Sources/AndroidLooper/Extensions/Duration.swift @@ -0,0 +1,14 @@ +// +// Duration.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 7/6/25. +// + +@available(macOS 13.0, *) +internal extension Duration { + + var milliseconds: Double { + Double(components.seconds) * 1000 + Double(components.attoseconds) * 1e-15 + } +} From ffaea459d0033cc45c0a3c62376f2b7d23dd8aa2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:56:55 -0400 Subject: [PATCH 34/58] Add `OptionSet` extensions --- .../AndroidLooper/Extensions/OptionSet.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Sources/AndroidLooper/Extensions/OptionSet.swift diff --git a/Sources/AndroidLooper/Extensions/OptionSet.swift b/Sources/AndroidLooper/Extensions/OptionSet.swift new file mode 100644 index 0000000..d986a80 --- /dev/null +++ b/Sources/AndroidLooper/Extensions/OptionSet.swift @@ -0,0 +1,38 @@ +// +// OptionSet.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 7/6/25. +// + +extension OptionSet { + // Helper method for building up a comma-separated list of options + // + // Taking an array of descriptions reduces code size vs + // a series of calls due to avoiding register copies. Make sure + // to pass an array literal and not an array built up from a series of + // append calls, else that will massively bloat code size. This takes + // StaticStrings because otherwise we get a warning about getting evicted + // from the shared cache. + @inline(never) + internal func _buildDescription( + _ descriptions: [(Element, StaticString)] + ) -> String { + var copy = self + var result = "[" + + for (option, name) in descriptions { + if _slowPath(copy.contains(option)) { + result += name.description + copy.remove(option) + if !copy.isEmpty { result += ", " } + } + } + + if _slowPath(!copy.isEmpty) { + result += "\(Self.self)(rawValue: \(copy.rawValue))" + } + result += "]" + return result + } +} From acc5ad69489924deb6fe6a788a0f4c6e7d87ca84 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:58:37 -0400 Subject: [PATCH 35/58] Add `Thread` extensions --- Sources/AndroidLooper/Extensions/Thread.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Sources/AndroidLooper/Extensions/Thread.swift diff --git a/Sources/AndroidLooper/Extensions/Thread.swift b/Sources/AndroidLooper/Extensions/Thread.swift new file mode 100644 index 0000000..4b61767 --- /dev/null +++ b/Sources/AndroidLooper/Extensions/Thread.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Foundation) +import Foundation + +public extension Thread { + + /** + * Prepares a looper associated with the calling thread, and returns it. + * If the thread already has a looper, it is returned. Otherwise, a new + * one is created, associated with the thread, and returned. + * + * The opts may be `ALOOPER_PREPARE_ALLOW_NON_CALLBACKS` or 0. + */ + @_alwaysEmitIntoClient + static func withLooper( + options: Looper.PrepareOptions = [], + _ body: (borrowing Looper) throws(E) -> T + ) throws(E) -> T { + try Looper.currentThread(options: options, body) + } +} +#endif From e9bd08b94bc1c1d075c051d10d8e0be181ebccf9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 16:58:52 -0400 Subject: [PATCH 36/58] Add license headers --- Sources/AndroidLooper/Extensions/Duration.swift | 13 ++++++++++--- Sources/AndroidLooper/Extensions/OptionSet.swift | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/AndroidLooper/Extensions/Duration.swift b/Sources/AndroidLooper/Extensions/Duration.swift index 35cfada..cf879eb 100644 --- a/Sources/AndroidLooper/Extensions/Duration.swift +++ b/Sources/AndroidLooper/Extensions/Duration.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Duration.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 7/6/25. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// @available(macOS 13.0, *) internal extension Duration { diff --git a/Sources/AndroidLooper/Extensions/OptionSet.swift b/Sources/AndroidLooper/Extensions/OptionSet.swift index d986a80..460a32d 100644 --- a/Sources/AndroidLooper/Extensions/OptionSet.swift +++ b/Sources/AndroidLooper/Extensions/OptionSet.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// OptionSet.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 7/6/25. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// extension OptionSet { // Helper method for building up a comma-separated list of options From c38e9bcd563bba75ea67e3009c0c58d35a3817d2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 17:02:42 -0400 Subject: [PATCH 37/58] Fix `SocketDescriptor.Event.Flags` --- Sources/AndroidSystem/SocketEvent.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AndroidSystem/SocketEvent.swift b/Sources/AndroidSystem/SocketEvent.swift index b21de0d..e078385 100644 --- a/Sources/AndroidSystem/SocketEvent.swift +++ b/Sources/AndroidSystem/SocketEvent.swift @@ -60,13 +60,13 @@ public extension SocketDescriptor.Event.Flags { /// /// See the description of the `O_CLOEXEC` flag in `open(2)` for reasons why this may be useful. @_alwaysEmitIntoClient - static var nonBlocking: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_CLOEXEC) } - + static var nonBlocking: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_NONBLOCK) } + /// Set the `O_NONBLOCK` file status flag on the new open file description. /// /// Using this flag saves extra calls to `fcntl(2)` to achieve the same result. @_alwaysEmitIntoClient - static var closeOnExec: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_NONBLOCK) } + static var closeOnExec: SocketDescriptor.Event.Flags { SocketDescriptor.Event.Flags(_EFD_CLOEXEC) } /// Provide semaphore-like semantics for reads from the new file descriptor. @_alwaysEmitIntoClient From 4105970ef14eb42e61c4871dd0bb4bd89c11d8ae Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 17:04:41 -0400 Subject: [PATCH 38/58] Add `system_eventfd()` --- Sources/AndroidSystem/Internals/Syscalls.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/AndroidSystem/Internals/Syscalls.swift b/Sources/AndroidSystem/Internals/Syscalls.swift index 3f1d225..e14bca2 100644 --- a/Sources/AndroidSystem/Internals/Syscalls.swift +++ b/Sources/AndroidSystem/Internals/Syscalls.swift @@ -271,3 +271,15 @@ internal func system_getenv( ) -> UnsafeMutablePointer? { return getenv(name) } + +#if os(Linux) || os(Android) +internal func system_eventfd( + _ initval: CUnsignedInt, + _ flags: CInt +) -> CInt { +#if ENABLE_MOCKING + if mockingEnabled { return _mock(initval, flags) } +#endif + return eventfd(initval, flags) +} +#endif From 4fe9e9c7124924ee84da0ab2d7de9974dd84eaef Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 17:12:42 -0400 Subject: [PATCH 39/58] Add `SocketDescriptor` operations --- Sources/AndroidSystem/SocketDescriptor.swift | 92 ++++++++++++++++ Sources/AndroidSystem/SocketEvent.swift | 106 +++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/Sources/AndroidSystem/SocketDescriptor.swift b/Sources/AndroidSystem/SocketDescriptor.swift index a8add0d..13a3bf6 100644 --- a/Sources/AndroidSystem/SocketDescriptor.swift +++ b/Sources/AndroidSystem/SocketDescriptor.swift @@ -26,3 +26,95 @@ public struct SocketDescriptor: RawRepresentable, Equatable, Hashable, Sendable public let rawValue: RawValue } + +// MARK: - Operations + +extension SocketDescriptor { + + /// Deletes a file descriptor. + /// + /// Deletes the file descriptor from the per-process object reference table. + /// If this is the last reference to the underlying object, + /// the object will be deactivated. + /// + /// The corresponding C function is `close`. + @_alwaysEmitIntoClient + public func close() throws(Errno) { try _close().get() } + + @usableFromInline + internal func _close() -> Result<(), Errno> { + nothingOrErrno(retryOnInterrupt: false) { system_close(self.rawValue) } + } + + + /// Reads bytes at the current file offset into a buffer. + /// + /// - Parameters: + /// - buffer: The region of memory to read into. + /// - retryOnInterrupt: Whether to retry the read operation + /// if it throws ``Errno/interrupted``. + /// The default is `true`. + /// Pass `false` to try only once and throw an error upon interruption. + /// - Returns: The number of bytes that were read. + /// + /// The property of `buffer` + /// determines the maximum number of bytes that are read into that buffer. + /// + /// After reading, + /// this method increments the file's offset by the number of bytes read. + /// To change the file's offset, + /// call the ``seek(offset:from:)`` method. + /// + /// The corresponding C function is `read`. + @_alwaysEmitIntoClient + public func read( + into buffer: UnsafeMutableRawBufferPointer, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + try _read(into: buffer, retryOnInterrupt: retryOnInterrupt).get() + } + + @usableFromInline + internal func _read( + into buffer: UnsafeMutableRawBufferPointer, + retryOnInterrupt: Bool + ) -> Result { + valueOrErrno(retryOnInterrupt: retryOnInterrupt) { + system_read(self.rawValue, buffer.baseAddress, buffer.count) + } + } + + /// Writes the contents of a buffer at the current file offset. + /// + /// - Parameters: + /// - buffer: The region of memory that contains the data being written. + /// - retryOnInterrupt: Whether to retry the write operation + /// if it throws ``Errno/interrupted``. + /// The default is `true`. + /// Pass `false` to try only once and throw an error upon interruption. + /// - Returns: The number of bytes that were written. + /// + /// After writing, + /// this method increments the file's offset by the number of bytes written. + /// To change the file's offset, + /// call the ``seek(offset:from:)`` method. + /// + /// The corresponding C function is `write`. + @_alwaysEmitIntoClient + public func write( + _ buffer: UnsafeRawBufferPointer, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + try _write(buffer, retryOnInterrupt: retryOnInterrupt).get() + } + + @usableFromInline + internal func _write( + _ buffer: UnsafeRawBufferPointer, + retryOnInterrupt: Bool + ) -> Result { + valueOrErrno(retryOnInterrupt: retryOnInterrupt) { + system_write(self.rawValue, buffer.baseAddress, buffer.count) + } + } +} diff --git a/Sources/AndroidSystem/SocketEvent.swift b/Sources/AndroidSystem/SocketEvent.swift index e078385..f0fbce3 100644 --- a/Sources/AndroidSystem/SocketEvent.swift +++ b/Sources/AndroidSystem/SocketEvent.swift @@ -121,4 +121,110 @@ extension SocketDescriptor.Event.Counter: CustomStringConvertible, CustomDebugSt public var debugDescription: String { description } } + +// MARK: - Operations + +extension SocketDescriptor.Event { + + internal var fileDescriptor: SocketDescriptor { .init(rawValue: rawValue) } + + /** + `eventfd()` creates an "eventfd object" that can be used as an event wait/notify mechanism by user-space applications, and by the kernel to notify user-space applications of events. + The object contains an unsigned 64-bit integer (uint64_t) counter that is maintained by the kernel. + This counter is initialized with the value specified in the argument initval. + */ + @usableFromInline + internal static func _events( + _ counter: CUnsignedInt, + flags: SocketDescriptor.Event.Flags, + retryOnInterrupt: Bool + ) -> Result { + valueOrErrno(retryOnInterrupt: retryOnInterrupt) { + system_eventfd(counter, flags.rawValue) + }.map({ SocketDescriptor.Event(rawValue: $0) }) + } + + @_alwaysEmitIntoClient + public init( + _ counter: CUnsignedInt = 0, + flags: SocketDescriptor.Event.Flags = [], + retryOnInterrupt: Bool = true + ) throws(Errno) { + self = try Self._events(counter, flags: flags, retryOnInterrupt: retryOnInterrupt).get() + } + + /// Deletes a file descriptor. + /// + /// Deletes the file descriptor from the per-process object reference table. + /// If this is the last reference to the underlying object, + /// the object will be deactivated. + /// + /// The corresponding C function is `close`. + @_alwaysEmitIntoClient + public func close() throws(Errno) { try _close().get() } + + @usableFromInline + internal func _close() -> Result<(), Errno> { + fileDescriptor._close() + } + + @usableFromInline + internal func _read( + retryOnInterrupt: Bool + ) -> Result { + var counter = Counter() + return withUnsafeMutableBytes(of: &counter.rawValue) { + fileDescriptor._read(into: $0, retryOnInterrupt: retryOnInterrupt) + }.map { assert($0 == 8) }.map { _ in counter } + } + + /** + Each successful `read(2)` returns an 8-byte integer. A read(2) will fail with the error EINVAL if the size of the supplied buffer is less than 8 bytes. + The value returned by read(2) is in host byte order, i.e., the native byte order for integers on the host machine. + The semantics of read(2) depend on whether the eventfd counter currently has a nonzero value and whether the EFD_SEMAPHORE flag was specified when creating the eventfd file descriptor: + + - If EFD_SEMAPHORE was not specified and the eventfd counter has a nonzero value, then a read(2) returns 8 bytes containing that value, and the counter's value is reset to zero. + - If EFD_SEMAPHORE was specified and the eventfd counter has a nonzero value, then a read(2) returns 8 bytes containing the value 1, and the counter's value is decremented by 1. + + If the eventfd counter is zero at the time of the call to read(2), then the call either blocks until the counter becomes nonzero (at which time, the read(2) proceeds as described above) or fails with the error EAGAIN if the file descriptor has been made nonblocking. + */ + @_alwaysEmitIntoClient + public func read( + retryOnInterrupt: Bool = true + ) throws(Errno) -> Counter { + try _read(retryOnInterrupt: retryOnInterrupt).get() + } + + /** + A write(2) call adds the 8-byte integer value supplied in + its buffer to the counter. The maximum value that may be + stored in the counter is the largest unsigned 64-bit value + minus 1 (i.e., 0xfffffffffffffffe). If the addition would + cause the counter's value to exceed the maximum, then the + write(2) either blocks until a read(2) is performed on the + file descriptor, or fails with the error EAGAIN if the file + descriptor has been made nonblocking. + + A write(2) fails with the error EINVAL if the size of the + supplied buffer is less than 8 bytes, or if an attempt is + made to write the value 0xffffffffffffffff. + */ + @_alwaysEmitIntoClient + public func write( + _ counter: Counter, + retryOnInterrupt: Bool = true + ) throws(Errno) { + try _write(counter, retryOnInterrupt: retryOnInterrupt).get() + } + + @usableFromInline + internal func _write( + _ counter: Counter, + retryOnInterrupt: Bool + ) -> Result<(), Errno> { + return withUnsafeBytes(of: counter.rawValue) { + fileDescriptor._write($0, retryOnInterrupt: retryOnInterrupt) + }.map { assert($0 == 8) } + } +} #endif From 9d89bab3aeda355ed8439e4bbaf9ff1743b72863 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 17:25:41 -0400 Subject: [PATCH 40/58] Optimize `Asset.read()` --- Sources/AndroidAssetManager/Asset.swift | 62 +++++++++++++++++-------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index 1feca5a..01080d9 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -64,34 +64,56 @@ public extension Asset { case end = 2 } - /// Reads up to `maxCount` bytes from the current cursor position. - func read(maxCount: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { - guard maxCount > 0 else { return [] } - var bytes = [UInt8](repeating: 0, count: maxCount) - let count = bytes.withUnsafeMutableBytes { - AAsset_read(pointer, $0.baseAddress, maxCount) + /// Reads up to `maxCount` bytes from the current cursor position, + /// then calls `body` with a ``RawSpan`` over the bytes read. + /// + /// The span is only valid for the duration of the call; do not escape it. + func read( + maxCount: Int = 4096, + _ body: (RawSpan) throws(E) -> T + ) throws -> T { + var bytes = [UInt8](repeating: 0, count: max(maxCount, 0)) + let count: Int32 + if maxCount > 0 { + count = bytes.withUnsafeMutableBytes { + AAsset_read(pointer, $0.baseAddress, maxCount) + } + guard count >= 0 else { throw AndroidFileManagerError.readAsset(count) } + } else { + count = 0 + } + return try bytes.withUnsafeBytes { + try body(UnsafeRawBufferPointer(rebasing: $0.prefix(Int(count))).bytes) } - guard count >= 0 else { throw .readAsset(count) } - bytes.removeSubrange(Int(count)...) - return bytes } - /// Reads and returns all remaining bytes. - func readAll(chunkSize: Int = 4096) throws(AndroidFileManagerError) -> [UInt8] { - guard chunkSize > 0 else { return [] } - // Fast path: asset is backed by a contiguous buffer — single copy. - if let bytes = withUnsafeBufferPointer({ Array($0) }) { - return bytes + /// Reads all remaining bytes, then calls `body` with a ``RawSpan`` over them. + /// + /// The span is only valid for the duration of the call; do not escape it. + func readAll( + chunkSize: Int = 4096, + _ body: (RawSpan) throws(E) -> T + ) throws -> T { + // Fast path: asset is backed by a contiguous buffer — zero allocation. + if let result = try withRawSpan({ try body($0) }) { + return result + } + // Slow path: accumulate chunks, then hand span over the full buffer. + guard chunkSize > 0 else { + return try body(UnsafeRawBufferPointer(start: nil, count: 0).bytes) } - // Slow path: chunked reads. var output = [UInt8]() output.reserveCapacity(Int(max(remainingLength, 0))) + var chunk = [UInt8](repeating: 0, count: chunkSize) while true { - let chunk = try read(maxCount: chunkSize) - if chunk.isEmpty { break } - output.append(contentsOf: chunk) + let count = chunk.withUnsafeMutableBytes { + AAsset_read(pointer, $0.baseAddress, chunkSize) + } + guard count >= 0 else { throw AndroidFileManagerError.readAsset(count) } + if count == 0 { break } + output.append(contentsOf: chunk.prefix(Int(count))) } - return output + return try output.withUnsafeBytes { try body($0.bytes) } } /// Seeks the asset cursor and returns the new absolute position. From 848072df6f0c915fd4ed47190b40105026b95f43 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 17:28:48 -0400 Subject: [PATCH 41/58] Add `Asset.read()` variants --- Sources/AndroidAssetManager/Asset.swift | 39 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/Sources/AndroidAssetManager/Asset.swift b/Sources/AndroidAssetManager/Asset.swift index 01080d9..a9a1a58 100644 --- a/Sources/AndroidAssetManager/Asset.swift +++ b/Sources/AndroidAssetManager/Asset.swift @@ -65,12 +65,10 @@ public extension Asset { } /// Reads up to `maxCount` bytes from the current cursor position, - /// then calls `body` with a ``RawSpan`` over the bytes read. - /// - /// The span is only valid for the duration of the call; do not escape it. + /// then calls `body` with an ``UnsafeRawBufferPointer`` over the bytes read. func read( maxCount: Int = 4096, - _ body: (RawSpan) throws(E) -> T + _ body: (UnsafeRawBufferPointer) throws(E) -> T ) throws -> T { var bytes = [UInt8](repeating: 0, count: max(maxCount, 0)) let count: Int32 @@ -83,24 +81,33 @@ public extension Asset { count = 0 } return try bytes.withUnsafeBytes { - try body(UnsafeRawBufferPointer(rebasing: $0.prefix(Int(count))).bytes) + try body(UnsafeRawBufferPointer(rebasing: $0.prefix(Int(count)))) } } - /// Reads all remaining bytes, then calls `body` with a ``RawSpan`` over them. + /// Reads up to `maxCount` bytes from the current cursor position, + /// then calls `body` with a ``RawSpan`` over the bytes read. /// /// The span is only valid for the duration of the call; do not escape it. + func read( + maxCount: Int = 4096, + _ body: (RawSpan) throws(E) -> T + ) throws -> T { + try read(maxCount: maxCount) { (buf: UnsafeRawBufferPointer) in try body(buf.bytes) } + } + + /// Reads all remaining bytes, then calls `body` with an ``UnsafeRawBufferPointer`` over them. func readAll( chunkSize: Int = 4096, - _ body: (RawSpan) throws(E) -> T + _ body: (UnsafeRawBufferPointer) throws(E) -> T ) throws -> T { // Fast path: asset is backed by a contiguous buffer — zero allocation. - if let result = try withRawSpan({ try body($0) }) { + if let result = try withUnsafeBufferPointer({ try body($0) }) { return result } - // Slow path: accumulate chunks, then hand span over the full buffer. + // Slow path: accumulate chunks, then hand buffer to body. guard chunkSize > 0 else { - return try body(UnsafeRawBufferPointer(start: nil, count: 0).bytes) + return try body(UnsafeRawBufferPointer(start: nil, count: 0)) } var output = [UInt8]() output.reserveCapacity(Int(max(remainingLength, 0))) @@ -113,7 +120,17 @@ public extension Asset { if count == 0 { break } output.append(contentsOf: chunk.prefix(Int(count))) } - return try output.withUnsafeBytes { try body($0.bytes) } + return try output.withUnsafeBytes { try body($0) } + } + + /// Reads all remaining bytes, then calls `body` with a ``RawSpan`` over them. + /// + /// The span is only valid for the duration of the call; do not escape it. + func readAll( + chunkSize: Int = 4096, + _ body: (RawSpan) throws(E) -> T + ) throws -> T { + try readAll(chunkSize: chunkSize) { (buf: UnsafeRawBufferPointer) in try body(buf.bytes) } } /// Seeks the asset cursor and returns the new absolute position. From 922793736c8b70885396139ae20d2d4904a41a8f Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 21 Mar 2026 16:48:49 -0400 Subject: [PATCH 42/58] Remove assertion that LogTag is empty --- Sources/AndroidLogging/LogTag.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/AndroidLogging/LogTag.swift b/Sources/AndroidLogging/LogTag.swift index 70b9a01..9539284 100644 --- a/Sources/AndroidLogging/LogTag.swift +++ b/Sources/AndroidLogging/LogTag.swift @@ -18,7 +18,6 @@ public struct LogTag: RawRepresentable, Equatable, Hashable, Codable, Sendable { public let rawValue: String public init(rawValue: String) { - assert(rawValue.isEmpty == false) self.rawValue = rawValue } } From 7abb0893fe8da180219197ae1edaebb6968c5dda Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 21 Mar 2026 16:26:48 -0400 Subject: [PATCH 43/58] Update AndroidContext to use SwiftJavaJNICore --- Package.swift | 11 +++ Sources/AndroidContext/AndroidContext.swift | 77 +++++++++++++++---- .../AndroidContextTests.swift | 2 - 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/Package.swift b/Package.swift index d4d9138..63d82c7 100644 --- a/Package.swift +++ b/Package.swift @@ -90,6 +90,17 @@ let package = Package( dependencies: [ "AndroidAssetManager" ]), + .target( + name: "AndroidContext", + dependencies: [ + "AndroidAssetManager", + .product(name: "SwiftJavaJNICore", package: "swift-java-jni-core"), + ]), + .testTarget( + name: "AndroidContextTests", + dependencies: [ + "AndroidContext" + ]), .target( name: "AndroidLogging", dependencies: [ diff --git a/Sources/AndroidContext/AndroidContext.swift b/Sources/AndroidContext/AndroidContext.swift index e50af89..f79203c 100644 --- a/Sources/AndroidContext/AndroidContext.swift +++ b/Sources/AndroidContext/AndroidContext.swift @@ -24,8 +24,8 @@ import func Darwin.getenv #elseif canImport(Glibc) import func Glibc.getenv #endif -@_exported import AndroidAssetManager -import SwiftJNI +import SwiftJavaJNICore +public import AndroidAssetManager /// A native reference to /// [android.content.Context](https://developer.android.com/reference/android/content/Context) @@ -33,7 +33,7 @@ import SwiftJNI @available(iOS, unavailable) @available(tvOS, unavailable) @available(watchOS, unavailable) -public class AndroidContext: JObject, @unchecked Sendable { +public class AndroidContext: @unchecked Sendable { /// The JNI signature for the method to invoke to obtain the global Context. /// This can be manually changed before initialization to a different signature. /// It must be a zero-argument static fuction that returns an instance of `android.content.Context`. @@ -43,7 +43,19 @@ public class AndroidContext: JObject, @unchecked Sendable { public static var contextFactory = getenv("SWIFT_ANDROID_CONTEXT_FACTORY").flatMap({ String(cString: $0) }) ?? "android.app.ActivityThread.currentApplication()Landroid/app/Application;" /// A global pointer to the application context, in case the application environment wants to initialize it directly without going through the factory method. - public static var contextPointer: JavaObjectPointer? = nil + public static var contextPointer: jobject? = nil + + /// The underlying JNI object pointer for this context. + public let pointer: jobject + + /// The JNI environment used by this context. + private let env: JNIEnvironment + + /// Initialize from an existing JNI object pointer and environment. + public init(pointer: jobject, env: JNIEnvironment) { + self.pointer = pointer + self.env = env + } /// Returns the application context. public static var application: AndroidContext { @@ -55,11 +67,13 @@ public class AndroidContext: JObject, @unchecked Sendable { /// Obtain the global application context by checking whether the static `contextPointer` is set, /// and if not, using the `contextFactory` string to reflectively look up the global context. private static let applicationContext: Result = Result(catching: { - try JNI.attachJVM() // ensure that we have a JNI context + let jvm: JavaVirtualMachine = try JavaVirtualMachine.shared() + let env: JNIEnvironment = try jvm.environment() + let jni: JNINativeInterface = env.pointee!.pointee // if we have provided a manual context jobject, then we just use that and skip trying to access the factory if let contextPointer = contextPointer { - return AndroidContext(contextPointer) + return AndroidContext(pointer: contextPointer, env: env) } // alternative fallback mechanism: @@ -79,24 +93,57 @@ public class AndroidContext: JObject, @unchecked Sendable { let contextMethod = "" + contextFunctionParts[0] let contextSig = "(" + contextFunctionParts[1] - let cls = try JClass(name: contextType) - guard let mth = cls.getStaticMethodID(name: contextMethod, sig: contextSig) else { + // Convert class name from dot notation to slash notation for JNI + let jniClassName = contextType.split(separator: ".").joined(separator: "/") + + guard let cls: jclass = jni.FindClass(env, jniClassName) else { + throw ContextError(errorDescription: "Unable to find class \(contextType)") + } + + guard let mth: jmethodID = jni.GetStaticMethodID(env, cls, contextMethod, contextSig) else { throw ContextError(errorDescription: "Unable to find method \(contextMethod)") } - let ctx: JavaObjectPointer = try cls.callStatic(method: mth, options: [], args: []) - return AndroidContext(ctx) - }) - private static let javaClass = try! JClass(name: "android/content/Context", systemClass: true) + guard let ctx: jobject = jni.CallStaticObjectMethodA(env, cls, mth, []) else { + throw ContextError(errorDescription: "Factory method \(contextMethod) returned null") + } + + return AndroidContext(pointer: ctx, env: env) + }) /// The `AndroidAssetManager` for this context - public private(set) lazy var assetManager = JNI.jni.withEnv { _, env in AndroidAssetManager(env: env, peer: self.safePointer()) } + public private(set) lazy var assetManager: AndroidAssetManager = { + let jni: JNINativeInterface = env.pointee!.pointee + + // Call context.getAssets() to get the Java AssetManager + let contextClass: jclass = jni.GetObjectClass(env, pointer)! + let getAssetsID: jmethodID = jni.GetMethodID(env, contextClass, "getAssets", "()Landroid/content/res/AssetManager;")! + let assetManagerObj: jobject = jni.CallObjectMethodA(env, pointer, getAssetsID, [])! + + return AndroidAssetManager(env: env, peer: assetManagerObj) + }() /// Returns the package name for the current context public func getPackageName() throws -> String? { - try call(method: Self.getPackageNameID, options: [], args: []) + let jni: JNINativeInterface = env.pointee!.pointee + + let contextClass: jclass = jni.GetObjectClass(env, pointer)! + guard let getPackageNameID: jmethodID = jni.GetMethodID(env, contextClass, "getPackageName", "()Ljava/lang/String;") else { + throw ContextError(errorDescription: "Unable to find getPackageName method") + } + + guard let javaString: jobject = jni.CallObjectMethodA(env, pointer, getPackageNameID, []) else { + return nil + } + + // Convert Java String to Swift String + guard let utf8Chars = jni.GetStringUTFChars(env, javaString, nil) else { + return nil + } + let result = String(cString: utf8Chars) + jni.ReleaseStringUTFChars(env, javaString, utf8Chars) + return result } - private static let getPackageNameID = javaClass.getMethodID(name: "getPackageName", sig: "()Ljava/lang/String;")! struct ContextError: LocalizedError { var errorDescription: String? diff --git a/Tests/AndroidContextTests/AndroidContextTests.swift b/Tests/AndroidContextTests/AndroidContextTests.swift index 57eb6e9..af6b528 100644 --- a/Tests/AndroidContextTests/AndroidContextTests.swift +++ b/Tests/AndroidContextTests/AndroidContextTests.swift @@ -21,8 +21,6 @@ import AndroidNDK #if !os(iOS) struct AndroidContextTests { - // TODO: activate these tests now that we have `skip android test --apk` and can access the JNI context - @Test(.disabled("this test is only for demo purposes")) func testAndroidContext() throws { #if os(Android) let nativeActivity: ANativeActivity! = nil From b884fa2fa497ea7feca4d9f995d70b16efc16c40 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 21 Mar 2026 16:44:02 -0400 Subject: [PATCH 44/58] Update AndroidContext --- .spi.yml | 1 + Package.swift | 1 + Tests/AndroidContextTests/AndroidContextTests.swift | 1 + Tests/AndroidLoggingTests/AndroidLoggingTests.swift | 4 ++-- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.spi.yml b/.spi.yml index ab01c63..9b3b391 100644 --- a/.spi.yml +++ b/.spi.yml @@ -4,6 +4,7 @@ metadata: builder: configs: - documentation_targets: + - 'AndroidContext' - 'AndroidAssetManager' - 'AndroidLogging' - 'AndroidLooper' diff --git a/Package.swift b/Package.swift index 63d82c7..847e234 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,7 @@ let package = Package( ], products: [ .library(name: "AndroidNative", targets: ["AndroidNative"]), + .library(name: "AndroidContext", targets: ["AndroidContext"]), .library(name: "AndroidAssetManager", targets: ["AndroidAssetManager"]), .library(name: "AndroidLogging", targets: ["AndroidLogging"]), .library(name: "AndroidLooper", targets: ["AndroidLooper"]), diff --git a/Tests/AndroidContextTests/AndroidContextTests.swift b/Tests/AndroidContextTests/AndroidContextTests.swift index af6b528..e515e1a 100644 --- a/Tests/AndroidContextTests/AndroidContextTests.swift +++ b/Tests/AndroidContextTests/AndroidContextTests.swift @@ -14,6 +14,7 @@ import Testing import AndroidContext +import AndroidAssetManager import SwiftJavaJNICore #if os(Android) import AndroidNDK diff --git a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift index 012c42b..96344b1 100644 --- a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift +++ b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift @@ -17,8 +17,8 @@ import OSLog // note: on non-android platforms, this will just export the system struct AndroidLoggingTests { @Test func testOSLogAPI() { - let emptyLogger = Logger() - emptyLogger.info("Android logger test: empty message") + //let emptyLogger = Logger() + //emptyLogger.info("Android logger test: empty message") let logger = Logger(subsystem: "AndroidLoggingTests", category: "test") From 389bdb9f6a744e2122b5d406656191137ac3c76c Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 21 Mar 2026 16:59:52 -0400 Subject: [PATCH 45/58] Add AndroidContext.setSharedContext() and update docs --- README.md | 104 ++++++++++++++---- Sources/AndroidContext/AndroidContext.swift | 24 +++- .../AndroidLoggingTests.swift | 4 + 3 files changed, 111 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c1d6b88..5e06a77 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,29 @@ dependencies: [ ] ``` +# JNI Dependencies and SwiftJava Interoperability + +This package depends only on [swiftlang/swift-java-jni-core](https://github.com/swiftlang/swift-java-jni-core), +a lightweight module that provides the JNI type definitions (`jobject`, `jclass`, `JNIEnvironment`, etc.), +the `JavaVirtualMachine` lifecycle manager, and the raw `JNINativeInterface` function table. +It does **not** depend on the full [swiftlang/swift-java](https://github.com/swiftlang/swift-java) bridge +or its higher-level abstractions (`JavaObject`, `JavaClass`, generated Java-to-Swift wrappers, etc.). + +This means SwiftAndroidNative can be used in projects that only need direct JNI access +without pulling in the larger swift-java dependency graph. + +However, SwiftAndroidNative is designed to **optionally interoperate** with swift-java. +Because both packages share the same underlying JNI types from swift-java-jni-core, +a `jobject` obtained through SwiftAndroidNative (such as `AndroidContext.pointer`) can be +passed directly to swift-java bridged APIs, and vice versa. For example, a context +`jobject` returned by a swift-java generated bridge class can be handed to +`AndroidContext.setSharedContext(_:env:)`, and an `AndroidContext.pointer` can be wrapped +in a swift-java `JavaObjectHolder` for use with generated Java class bindings. + +If your project uses swift-java, add it as a separate dependency alongside swift-android-native; +the two will share the same `JavaVirtualMachine` instance and JNI environment without conflict. + + # AndroidLogging This module provides a Logger API for native Swift on Android compatible with @@ -90,7 +113,8 @@ function. # AndroidContext This module provides a minimal wrapper for [android.content.Context](https://developer.android.com/reference/android/content/Context) -that uses [SwiftJNI](https://github.com/skiptools/swift-jni) to bridge into the global application context. +that uses raw JNI calls (via [SwiftJavaJNICore](https://github.com/swiftlang/swift-java-jni-core)) +to bridge into the global application context. ## Installation @@ -109,37 +133,77 @@ let context = try AndroidContext.application let packageName = try context.getPackageName() ``` -## Internals +## Bootstrapping the Context -### Implementation details +The recommended way to initialize `AndroidContext` is to call `setSharedContext(_:env:)` +as early as possible — before any code accesses `AndroidContext.application`. This avoids +the automatic JVM lookup and reflective factory call entirely, giving you full control over +how the context is provided. + +### From `JNI_OnLoad` -By default, the `AndroidContext.application` accessor will try to invoke the JNI method -`android.app.ActivityThread.currentApplication()Landroid/app/Application;` to obtain the -global application context. This can be overridden at app initialization time by setting -the `SWIFT_ANDROID_CONTEXT_FACTORY` environment to a different static accessor, such as: +If your Swift code is loaded as a shared library by Java (e.g. via `System.loadLibrary`), +implement the standard `JNI_OnLoad` entry point. The `JavaVM` pointer gives you a +`JNIEnvironment`, and you can then look up the application context: ```swift -// another way to access the global context (deprecated) -setenv("SWIFT_ANDROID_CONTEXT_FACTORY", "android.app.AppGlobals.getInitialApplication()Landroid/app/Application;", 1) +import SwiftJavaJNICore +import AndroidContext + +@_cdecl("JNI_OnLoad") +public func JNI_OnLoad(_ jvm: UnsafeMutablePointer, _ reserved: UnsafeMutableRawPointer?) -> jint { + // Adopt the JVM so SwiftJavaJNICore knows about it + let vm = JavaVirtualMachine(adoptingJVM: jvm) + JavaVirtualMachine.setSharedJVM(vm) + let env = try! vm.environment() + let jni = env.pointee!.pointee + + // Look up the application context via ActivityThread + let cls = jni.FindClass(env, "android/app/ActivityThread")! + let mid = jni.GetStaticMethodID(env, cls, "currentApplication", "()Landroid/app/Application;")! + let app = jni.CallStaticObjectMethodA(env, cls, mid, [])! + let globalRef = jni.NewGlobalRef(env, app)! // prevent GC + + AndroidContext.setSharedContext(globalRef, env: env) + return jint(JNI_VERSION_1_6) +} +``` -let context = try AndroidContext.application +### From SwiftJava / swift-java bridged code + +If you are using the full [swift-java](https://github.com/swiftlang/swift-java) bridge, +the JVM is already set up for you. You can obtain the environment from +`JavaVirtualMachine.shared()` and pass in a context `jobject` from the bridged Java side: + +```swift +let jvm = try JavaVirtualMachine.shared() +let env = try jvm.environment() +AndroidContext.setSharedContext(someContextJobject, env: env) ``` -Such setup must be performed before the first time the `AndroidContext.application` -accessor is called, as the result will be cached the first time it is invoked. +### From an `ANativeActivity` -Alternatively, if the application bootstrapping code already has access to a -JNI context and `jobject` reference to the application context, it can be -set directly in the static `contextPointer` field. For example, -if your application uses an NDK [ANativeActivity](https://developer.android.com/ndk/reference/struct/a-native-activity) -activity, then the context can be accessed from its reference to the underlying -[android.app.NativeActivity](https://developer.android.com/reference/android/app/NativeActivity) -instance: +If your application uses an NDK [ANativeActivity](https://developer.android.com/ndk/reference/struct/a-native-activity), +you can set the context pointer directly: ```swift let nativeActivity: ANativeActivity = … AndroidContext.contextPointer = nativeActivity.clazz -let context = try AndroidContext.application // returns the wrapper around the application context +let context = try AndroidContext.application +``` + +### Automatic fallback + +If `setSharedContext` is never called and `contextPointer` is not set, +`AndroidContext.application` will attempt to locate the JVM automatically using +`JavaVirtualMachine.shared()` and then reflectively invoke the factory method +`android.app.ActivityThread.currentApplication()` to obtain the global context. +This can be overridden by setting the `SWIFT_ANDROID_CONTEXT_FACTORY` environment +variable to a different static accessor before the first access: + +```swift +setenv("SWIFT_ANDROID_CONTEXT_FACTORY", "android.app.AppGlobals.getInitialApplication()Landroid/app/Application;", 1) +let context = try AndroidContext.application ``` diff --git a/Sources/AndroidContext/AndroidContext.swift b/Sources/AndroidContext/AndroidContext.swift index f79203c..427d5ad 100644 --- a/Sources/AndroidContext/AndroidContext.swift +++ b/Sources/AndroidContext/AndroidContext.swift @@ -57,10 +57,32 @@ public class AndroidContext: @unchecked Sendable { self.env = env } + /// Sets a pre-initialized Android context directly, bypassing the automatic JVM and context + /// lookup performed by the `application` accessor. + /// + /// Call this method early in your application's lifecycle — for example, from a `JNI_OnLoad` + /// function or an `ANativeActivity` callback — before any code accesses `AndroidContext.application`. + /// Once the shared context is set, `application` will return it immediately without attempting to + /// locate the JVM or invoke the `contextFactory` reflective lookup. + /// + /// - Parameter context: A JNI `jobject` reference to an `android.content.Context` (or subclass + /// such as `android.app.Application`). The caller is responsible for ensuring this reference + /// remains valid for the lifetime of the process (typically a global ref). + /// - Parameter env: The JNI environment for the current thread. + public static func setSharedContext(_ context: jobject, env: JNIEnvironment) { + sharedContext = AndroidContext(pointer: context, env: env) + } + + /// A manually provided shared context, set via `setSharedContext(_:env:)`. + private static var sharedContext: AndroidContext? = nil + /// Returns the application context. public static var application: AndroidContext { get throws { - try applicationContext.get() + if let sharedContext = sharedContext { + return sharedContext + } + return try applicationContext.get() } } diff --git a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift index 96344b1..8e1e225 100644 --- a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift +++ b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift @@ -13,7 +13,11 @@ //===----------------------------------------------------------------------===// import Testing +#if canImport(OSLog) import OSLog // note: on non-android platforms, this will just export the system OSLog +#else +import AndroidLogging +#endif struct AndroidLoggingTests { @Test func testOSLogAPI() { From e6cdfdba03ba3bdc6a513ea2e205649e24c558cb Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 21 Mar 2026 17:14:26 -0400 Subject: [PATCH 46/58] Fix logging imports and update README.md --- README.md | 6 +++--- Sources/OSLog/AndroidLogging.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5e06a77..dc6691a 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,13 @@ dependencies: [ This package depends only on [swiftlang/swift-java-jni-core](https://github.com/swiftlang/swift-java-jni-core), a lightweight module that provides the JNI type definitions (`jobject`, `jclass`, `JNIEnvironment`, etc.), the `JavaVirtualMachine` lifecycle manager, and the raw `JNINativeInterface` function table. -It does **not** depend on the full [swiftlang/swift-java](https://github.com/swiftlang/swift-java) bridge +It does *not* depend on the full [swiftlang/swift-java](https://github.com/swiftlang/swift-java) bridge or its higher-level abstractions (`JavaObject`, `JavaClass`, generated Java-to-Swift wrappers, etc.). This means SwiftAndroidNative can be used in projects that only need direct JNI access without pulling in the larger swift-java dependency graph. -However, SwiftAndroidNative is designed to **optionally interoperate** with swift-java. +However, SwiftAndroidNative is designed to optionally interoperate with swift-java. Because both packages share the same underlying JNI types from swift-java-jni-core, a `jobject` obtained through SwiftAndroidNative (such as `AndroidContext.pointer`) can be passed directly to swift-java bridged APIs, and vice versa. For example, a context @@ -184,7 +184,7 @@ AndroidContext.setSharedContext(someContextJobject, env: env) ### From an `ANativeActivity` If your application uses an NDK [ANativeActivity](https://developer.android.com/ndk/reference/struct/a-native-activity), -you can set the context pointer directly: +you can set the context pointer directly using the (misnamed) [`clazz`](https://developer.android.com/ndk/reference/struct/a-native-activity#struct_a_native_activity_1abbde1ec6b9af24c517a604f0d401b274) pointer: ```swift let nativeActivity: ANativeActivity = … diff --git a/Sources/OSLog/AndroidLogging.swift b/Sources/OSLog/AndroidLogging.swift index 615c17c..0f0a694 100644 --- a/Sources/OSLog/AndroidLogging.swift +++ b/Sources/OSLog/AndroidLogging.swift @@ -17,7 +17,7 @@ import Android import CAndroidNDK #endif -#if canImport(os) +#if canImport(OSLog) @_exported import OSLog #else import AndroidLogging From 2aec8e2834b6eeffcd59e1eb7cc17be1b85bc0c5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 18:38:44 -0400 Subject: [PATCH 47/58] Fix merge conflicts --- Sources/CAndroidNDK/dummy.c | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Sources/CAndroidNDK/dummy.c b/Sources/CAndroidNDK/dummy.c index e0d062a..2940afb 100644 --- a/Sources/CAndroidNDK/dummy.c +++ b/Sources/CAndroidNDK/dummy.c @@ -12,15 +12,6 @@ // //===----------------------------------------------------------------------===// -<<<<<<<< HEAD:Sources/AndroidLooper/Extensions/Duration.swift -@available(macOS 13.0, *) -internal extension Duration { - - var milliseconds: Double { - Double(components.seconds) * 1000 + Double(components.attoseconds) * 1e-15 - } -} -======== #ifdef __ANDROID__ #include @@ -28,4 +19,3 @@ internal extension Duration { #include #endif ->>>>>>>> main:Sources/CAndroidNDK/dummy.c From 3c684213798cb6d5446bda30efe5e7a3bed018af Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 18:49:18 -0400 Subject: [PATCH 48/58] Add `AndroidContextError` --- Sources/AndroidContext/Error.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Sources/AndroidContext/Error.swift diff --git a/Sources/AndroidContext/Error.swift b/Sources/AndroidContext/Error.swift new file mode 100644 index 0000000..069018d --- /dev/null +++ b/Sources/AndroidContext/Error.swift @@ -0,0 +1,14 @@ +// +// Error.swift +// swift-android-native +// +// Created by Alsey Coleman Miller on 3/21/26. +// + +public enum AndroidContextError: Error { + + case classNotFound(String) + case methodNotFound(String) + case nullValueForMethod(String) + case invalidJavaSignature(String) +} From 87135d9083df1d7afd0c1be19730c34ffff61bf5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 18:54:14 -0400 Subject: [PATCH 49/58] Add license header --- Sources/AndroidContext/Error.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/AndroidContext/Error.swift b/Sources/AndroidContext/Error.swift index 069018d..66dee5b 100644 --- a/Sources/AndroidContext/Error.swift +++ b/Sources/AndroidContext/Error.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Error.swift -// swift-android-native +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 3/21/26. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// public enum AndroidContextError: Error { From a58f198c8bfd54d19d44be20420387afb997a11c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 18:54:24 -0400 Subject: [PATCH 50/58] Update `AndroidContext` for Swift 6.2 --- Sources/AndroidContext/AndroidContext.swift | 34 ++++++++++----------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/Sources/AndroidContext/AndroidContext.swift b/Sources/AndroidContext/AndroidContext.swift index e266a09..42278a2 100644 --- a/Sources/AndroidContext/AndroidContext.swift +++ b/Sources/AndroidContext/AndroidContext.swift @@ -40,10 +40,12 @@ public class AndroidContext: @unchecked Sendable { /// /// The default value of the factory will be the value of the `SWIFT_ANDROID_CONTEXT_FACTORY` environment variable, /// and if unset, will fall back to `android.app.ActivityThread.currentApplication()Landroid/app/Application;`. - public static var contextFactory = getenv("SWIFT_ANDROID_CONTEXT_FACTORY").flatMap({ String(cString: $0) }) ?? "android.app.ActivityThread.currentApplication()Landroid/app/Application;" + public static var contextFactory: String { + getenv("SWIFT_ANDROID_CONTEXT_FACTORY").flatMap({ String(cString: $0) }) ?? "android.app.ActivityThread.currentApplication()Landroid/app/Application;" + } /// A global pointer to the application context, in case the application environment wants to initialize it directly without going through the factory method. - public static var contextPointer: jobject? = nil + public nonisolated(unsafe) static var contextPointer: jobject? = nil /// The underlying JNI object pointer for this context. public let pointer: jobject @@ -74,11 +76,11 @@ public class AndroidContext: @unchecked Sendable { } /// A manually provided shared context, set via `setSharedContext(_:env:)`. - private static var sharedContext: AndroidContext? = nil + private nonisolated(unsafe) static var sharedContext: AndroidContext? = nil /// Returns the application context. public static var application: AndroidContext { - get throws { + get throws(AndroidContextError) { if let sharedContext = sharedContext { return sharedContext } @@ -88,14 +90,14 @@ public class AndroidContext: @unchecked Sendable { /// Obtain the global application context by checking whether the static `contextPointer` is set, /// and if not, using the `contextFactory` string to reflectively look up the global context. - private static let applicationContext: Result = Result(catching: { + private static let applicationContext: Result = { let jvm: JavaVirtualMachine = try JavaVirtualMachine.shared() let env: JNIEnvironment = try jvm.environment() let jni: JNINativeInterface = env.pointee!.pointee // if we have provided a manual context jobject, then we just use that and skip trying to access the factory if let contextPointer = contextPointer { - return AndroidContext(pointer: contextPointer, env: env) + return .success(AndroidContext(pointer: contextPointer, env: env)) } // alternative fallback mechanism: @@ -109,7 +111,7 @@ public class AndroidContext: @unchecked Sendable { // get the second part of the contextFactory parameter: currentApplication()Landroid/app/Application; let contextFunctionParts = contextRemainder.split(separator: "(") if contextFunctionParts.count != 2 { - throw ContextError(errorDescription: "Invalid contextFactory signature: \(contextFactory)") + return .failure(.invalidJavaSignature(contextFactory)) } let contextMethod = "" + contextFunctionParts[0] @@ -119,19 +121,19 @@ public class AndroidContext: @unchecked Sendable { let jniClassName = contextType.split(separator: ".").joined(separator: "/") guard let cls: jclass = jni.FindClass(env, jniClassName) else { - throw ContextError(errorDescription: "Unable to find class \(contextType)") + return .failure(.classNotFound(contextType)) } guard let mth: jmethodID = jni.GetStaticMethodID(env, cls, contextMethod, contextSig) else { - throw ContextError(errorDescription: "Unable to find method \(contextMethod)") + return .failure(.methodNotFound(contextMethod)) } guard let ctx: jobject = jni.CallStaticObjectMethodA(env, cls, mth, []) else { - throw ContextError(errorDescription: "Factory method \(contextMethod) returned null") + return .failure(.nullValueForMethod(contextMethod)) } - return AndroidContext(pointer: ctx, env: env) - }) + return .success(AndroidContext(pointer: ctx, env: env)) + }() /// The `AndroidAssetManager` for this context public var assetManager: AssetManager { @@ -146,12 +148,12 @@ public class AndroidContext: @unchecked Sendable { } /// Returns the package name for the current context - public func getPackageName() throws -> String? { + public func getPackageName() throws(AndroidContextError) -> String? { let jni: JNINativeInterface = env.pointee!.pointee let contextClass: jclass = jni.GetObjectClass(env, pointer)! guard let getPackageNameID: jmethodID = jni.GetMethodID(env, contextClass, "getPackageName", "()Ljava/lang/String;") else { - throw ContextError(errorDescription: "Unable to find getPackageName method") + throw AndroidContextError.methodNotFound("getPackageName") } guard let javaString: jobject = jni.CallObjectMethodA(env, pointer, getPackageNameID, []) else { @@ -166,8 +168,4 @@ public class AndroidContext: @unchecked Sendable { jni.ReleaseStringUTFChars(env, javaString, utf8Chars) return result } - - struct ContextError: LocalizedError { - var errorDescription: String? - } } From 9c24bc0f06578d15b8d9b9cc79bfea74e9246a2c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:08:24 -0400 Subject: [PATCH 51/58] Add `AndroidLooperError` --- Sources/AndroidLooper/Error.swift | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Sources/AndroidLooper/Error.swift diff --git a/Sources/AndroidLooper/Error.swift b/Sources/AndroidLooper/Error.swift new file mode 100644 index 0000000..035efcb --- /dev/null +++ b/Sources/AndroidLooper/Error.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AndroidSystem + +/// Android Looper Error +public enum AndroidLooperError: Swift.Error { + + /// Underlying Bionic Error + case bionic(Errno) + + case addFileDescriptor(FileDescriptor) + + /// Unable to remove the file descriptor. + case removeFileDescriptor(FileDescriptor) + + /// File Descriptor not registered + case fileDescriptorNotRegistered(FileDescriptor) + + /// Poll Timeout + case pollTimeout + + /// Poll Error + case pollError +} From d7e048c79c6f50b50990b22e5044da2cf6272372 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:36:44 -0400 Subject: [PATCH 52/58] Remove `LockedState` --- Sources/AndroidSystem/LockedState.swift | 160 ------------------------ 1 file changed, 160 deletions(-) delete mode 100644 Sources/AndroidSystem/LockedState.swift diff --git a/Sources/AndroidSystem/LockedState.swift b/Sources/AndroidSystem/LockedState.swift deleted file mode 100644 index b740528..0000000 --- a/Sources/AndroidSystem/LockedState.swift +++ /dev/null @@ -1,160 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAndroidNative open source project -// -// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if canImport(os) -import os -#if FOUNDATION_FRAMEWORK && canImport(C.os.lock) -import C.os.lock -#endif -#elseif canImport(Bionic) -import Bionic -#elseif canImport(Glibc) -import Glibc -#elseif canImport(Musl) -import Musl -#elseif canImport(WinSDK) -import WinSDK -#endif - -public struct LockedState { - // Internal implementation for a cheap lock to aid sharing code across platforms - private struct _Lock { -#if canImport(os) - typealias Primitive = os_unfair_lock -#elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) - typealias Primitive = pthread_mutex_t -#elseif canImport(WinSDK) - typealias Primitive = SRWLOCK -#elseif os(WASI) - // WASI is single-threaded, so we don't need a lock. - typealias Primitive = () -#endif - - typealias PlatformLock = UnsafeMutablePointer - var _platformLock: PlatformLock - - fileprivate static func initialize(_ platformLock: PlatformLock) { -#if canImport(os) - platformLock.initialize(to: os_unfair_lock()) -#elseif canImport(Bionic) || canImport(Glibc) - pthread_mutex_init(platformLock, nil) -#elseif canImport(WinSDK) - InitializeSRWLock(platformLock) -#elseif os(WASI) - // no-op -#endif - } - - fileprivate static func deinitialize(_ platformLock: PlatformLock) { -#if canImport(Bionic) || canImport(Glibc) - pthread_mutex_destroy(platformLock) -#endif - platformLock.deinitialize(count: 1) - } - - fileprivate static func lock(_ platformLock: PlatformLock) { -#if canImport(os) - os_unfair_lock_lock(platformLock) -#elseif canImport(Bionic) || canImport(Glibc) - pthread_mutex_lock(platformLock) -#elseif canImport(WinSDK) - AcquireSRWLockExclusive(platformLock) -#elseif os(WASI) - // no-op -#endif - } - - fileprivate static func unlock(_ platformLock: PlatformLock) { -#if canImport(os) - os_unfair_lock_unlock(platformLock) -#elseif canImport(Bionic) || canImport(Glibc) - pthread_mutex_unlock(platformLock) -#elseif canImport(WinSDK) - ReleaseSRWLockExclusive(platformLock) -#elseif os(WASI) - // no-op -#endif - } - } - - private class _Buffer: ManagedBuffer { - deinit { - withUnsafeMutablePointerToElements { - _Lock.deinitialize($0) - } - } - } - - private let _buffer: ManagedBuffer - - public init(initialState: State) { - _buffer = _Buffer.create(minimumCapacity: 1, makingHeaderWith: { buf in - buf.withUnsafeMutablePointerToElements { - _Lock.initialize($0) - } - return initialState - }) - } - - public func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { - try withLockUnchecked(body) - } - - public func withLockUnchecked(_ body: (inout State) throws -> T) rethrows -> T { - try _buffer.withUnsafeMutablePointers { state, lock in - _Lock.lock(lock) - defer { _Lock.unlock(lock) } - return try body(&state.pointee) - } - } - - // Ensures the managed state outlives the locked scope. - public func withLockExtendingLifetimeOfState(_ body: @Sendable (inout State) throws -> T) rethrows - -> T - { - try _buffer.withUnsafeMutablePointers { state, lock in - _Lock.lock(lock) - return try withExtendedLifetime(state.pointee) { - defer { _Lock.unlock(lock) } - return try body(&state.pointee) - } - } - } -} - -public extension LockedState where State == () { - init() { - self.init(initialState: ()) - } - - func withLock(_ body: @Sendable () throws -> R) rethrows -> R { - try withLock { _ in - try body() - } - } - - func lock() { - _buffer.withUnsafeMutablePointerToElements { lock in - _Lock.lock(lock) - } - } - - func unlock() { - _buffer.withUnsafeMutablePointerToElements { lock in - _Lock.unlock(lock) - } - } -} - -extension LockedState: @unchecked Sendable where State: Sendable {} From 33c70b0b57946fa01a54108a1454e817a50f87a6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:47:41 -0400 Subject: [PATCH 53/58] Add `Duration` extensions --- Sources/AndroidLooper/Extensions/Duration.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Sources/AndroidLooper/Extensions/Duration.swift diff --git a/Sources/AndroidLooper/Extensions/Duration.swift b/Sources/AndroidLooper/Extensions/Duration.swift new file mode 100644 index 0000000..35cfada --- /dev/null +++ b/Sources/AndroidLooper/Extensions/Duration.swift @@ -0,0 +1,14 @@ +// +// Duration.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 7/6/25. +// + +@available(macOS 13.0, *) +internal extension Duration { + + var milliseconds: Double { + Double(components.seconds) * 1000 + Double(components.attoseconds) * 1e-15 + } +} From bfb2197542ef1a5b93922adb3770394afc7db48e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:49:16 -0400 Subject: [PATCH 54/58] Restore `LockedState` --- Sources/AndroidSystem/LockedState.swift | 160 ++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 Sources/AndroidSystem/LockedState.swift diff --git a/Sources/AndroidSystem/LockedState.swift b/Sources/AndroidSystem/LockedState.swift new file mode 100644 index 0000000..864a182 --- /dev/null +++ b/Sources/AndroidSystem/LockedState.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAndroidNative open source project +// +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(os) +import os +#if FOUNDATION_FRAMEWORK && canImport(C.os.lock) +import C.os.lock +#endif +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(WinSDK) +import WinSDK +#endif + +package struct LockedState { + + // Internal implementation for a cheap lock to aid sharing code across platforms + private struct _Lock { + #if canImport(os) + typealias Primitive = os_unfair_lock + #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) + typealias Primitive = pthread_mutex_t + #elseif canImport(WinSDK) + typealias Primitive = SRWLOCK + #elseif os(WASI) + // WASI is single-threaded, so we don't need a lock. + typealias Primitive = Void + #endif + + typealias PlatformLock = UnsafeMutablePointer + var _platformLock: PlatformLock + + fileprivate static func initialize(_ platformLock: PlatformLock) { + #if canImport(os) + platformLock.initialize(to: os_unfair_lock()) + #elseif canImport(Bionic) || canImport(Glibc) + pthread_mutex_init(platformLock, nil) + #elseif canImport(WinSDK) + InitializeSRWLock(platformLock) + #elseif os(WASI) + // no-op + #endif + } + + fileprivate static func deinitialize(_ platformLock: PlatformLock) { + #if canImport(Bionic) || canImport(Glibc) + pthread_mutex_destroy(platformLock) + #endif + platformLock.deinitialize(count: 1) + } + + static fileprivate func lock(_ platformLock: PlatformLock) { + #if canImport(os) + os_unfair_lock_lock(platformLock) + #elseif canImport(Bionic) || canImport(Glibc) + pthread_mutex_lock(platformLock) + #elseif canImport(WinSDK) + AcquireSRWLockExclusive(platformLock) + #elseif os(WASI) + // no-op + #endif + } + + static fileprivate func unlock(_ platformLock: PlatformLock) { + #if canImport(os) + os_unfair_lock_unlock(platformLock) + #elseif canImport(Bionic) || canImport(Glibc) + pthread_mutex_unlock(platformLock) + #elseif canImport(WinSDK) + ReleaseSRWLockExclusive(platformLock) + #elseif os(WASI) + // no-op + #endif + } + } + + private class _Buffer: ManagedBuffer { + deinit { + withUnsafeMutablePointerToElements { + _Lock.deinitialize($0) + } + } + } + + private let _buffer: ManagedBuffer + + package init(initialState: State) { + _buffer = _Buffer.create( + minimumCapacity: 1, + makingHeaderWith: { buf in + buf.withUnsafeMutablePointerToElements { + _Lock.initialize($0) + } + return initialState + } + ) + } + + package func withLock(_ body: @Sendable (inout State) throws(E) -> T) throws(E) -> T { + try withLockUnchecked(body) + } + + package func withLockUnchecked(_ body: (inout State) throws(E) -> T) throws(E) -> T { + _buffer.withUnsafeMutablePointerToElements { _Lock.lock($0) } + defer { _buffer.withUnsafeMutablePointerToElements { _Lock.unlock($0) } } + return try body(&_buffer.header) + } + + // Ensures the managed state outlives the locked scope. + package func withLockExtendingLifetimeOfState(_ body: @Sendable (inout State) throws(E) -> T) throws(E) -> T { + _buffer.withUnsafeMutablePointerToElements { _Lock.lock($0) } + defer { _buffer.withUnsafeMutablePointerToElements { _Lock.unlock($0) } } + do { + return try body(&_buffer.header) + } catch { + throw error + } + } +} + +extension LockedState where State == Void { + package init() { + self.init(initialState: ()) + } + + package func withLock(_ body: @Sendable () throws(E) -> R) throws(E) -> R { + _buffer.withUnsafeMutablePointerToElements { _Lock.lock($0) } + defer { _buffer.withUnsafeMutablePointerToElements { _Lock.unlock($0) } } + return try body() + } + + package func lock() { + _buffer.withUnsafeMutablePointerToElements { lock in + _Lock.lock(lock) + } + } + + package func unlock() { + _buffer.withUnsafeMutablePointerToElements { lock in + _Lock.unlock(lock) + } + } +} + +extension LockedState: @unchecked Sendable where State: Sendable {} From 6df09b59ffe8dc4db16a7bbc387f36a381f823a5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:50:14 -0400 Subject: [PATCH 55/58] Add `AndroidContextError.virtualMachine` --- Sources/AndroidContext/Error.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/AndroidContext/Error.swift b/Sources/AndroidContext/Error.swift index 66dee5b..05b966d 100644 --- a/Sources/AndroidContext/Error.swift +++ b/Sources/AndroidContext/Error.swift @@ -12,10 +12,13 @@ // //===----------------------------------------------------------------------===// +import SwiftJavaJNICore + public enum AndroidContextError: Error { case classNotFound(String) case methodNotFound(String) case nullValueForMethod(String) - case invalidJavaSignature(String) + case invalidSignature(String) + case virtualMachine(JavaVirtualMachine.VMError) } From 0e9322a9176b9572602ce865931b1dfef85e69df Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:50:26 -0400 Subject: [PATCH 56/58] Updated unit tests --- Tests/AndroidContextTests/AndroidContextTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/AndroidContextTests/AndroidContextTests.swift b/Tests/AndroidContextTests/AndroidContextTests.swift index 95421d2..6dd0cb3 100644 --- a/Tests/AndroidContextTests/AndroidContextTests.swift +++ b/Tests/AndroidContextTests/AndroidContextTests.swift @@ -26,8 +26,9 @@ struct AndroidContextTests { let context = try AndroidContext.application logger.info("context package name: \(try context.getPackageName() ?? "")") #expect(try context.getPackageName() == "org.swift.test.swift_android_native") // the default package name in `skip android test --apk` - let assetManager: AndroidAssetManager = context.assetManager - for item in assetManager.listAssets(inDirectory: "") ?? [] { + let assetManager: AssetManager = context.assetManager + var directory = try assetManager.openDirectory("") + while let item = directory.next() { print("asset item: \(item)") } #endif From 063c2a943421c9ceecb13fd34fdf202945e6a4e0 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:51:18 -0400 Subject: [PATCH 57/58] Fix `AndroidContext.applicationContext` --- Sources/AndroidContext/AndroidContext.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/AndroidContext/AndroidContext.swift b/Sources/AndroidContext/AndroidContext.swift index 42278a2..d12cafb 100644 --- a/Sources/AndroidContext/AndroidContext.swift +++ b/Sources/AndroidContext/AndroidContext.swift @@ -91,8 +91,18 @@ public class AndroidContext: @unchecked Sendable { /// Obtain the global application context by checking whether the static `contextPointer` is set, /// and if not, using the `contextFactory` string to reflectively look up the global context. private static let applicationContext: Result = { - let jvm: JavaVirtualMachine = try JavaVirtualMachine.shared() - let env: JNIEnvironment = try jvm.environment() + let jvm: JavaVirtualMachine + let env: JNIEnvironment + do { + jvm = try JavaVirtualMachine.shared() + env = try jvm.environment() + } + catch let error as JavaVirtualMachine.VMError { + return .failure(.virtualMachine(error)) + } + catch { + fatalError("Non-JavaVirtualMachine.VMError error thrown") + } let jni: JNINativeInterface = env.pointee!.pointee // if we have provided a manual context jobject, then we just use that and skip trying to access the factory @@ -111,7 +121,7 @@ public class AndroidContext: @unchecked Sendable { // get the second part of the contextFactory parameter: currentApplication()Landroid/app/Application; let contextFunctionParts = contextRemainder.split(separator: "(") if contextFunctionParts.count != 2 { - return .failure(.invalidJavaSignature(contextFactory)) + return .failure(.invalidSignature(contextFactory)) } let contextMethod = "" + contextFunctionParts[0] From e2593dadfd12b83acd810acebd43ac45f8ac49de Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 21 Mar 2026 19:54:11 -0400 Subject: [PATCH 58/58] Add license header --- Sources/AndroidLooper/Extensions/Duration.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/AndroidLooper/Extensions/Duration.swift b/Sources/AndroidLooper/Extensions/Duration.swift index 35cfada..cf879eb 100644 --- a/Sources/AndroidLooper/Extensions/Duration.swift +++ b/Sources/AndroidLooper/Extensions/Duration.swift @@ -1,9 +1,16 @@ +//===----------------------------------------------------------------------===// // -// Duration.swift -// SwiftAndroid +// This source file is part of the SwiftAndroidNative open source project // -// Created by Alsey Coleman Miller on 7/6/25. +// Copyright (c) 2024-2026 Skip.dev and SwiftAndroidNative project authors +// Licensed under Apache License v2.0 // +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAndroidNative project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// @available(macOS 13.0, *) internal extension Duration {