diff --git a/Package.swift b/Package.swift index 3af2f77..847e234 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 //===----------------------------------------------------------------------===// // // This source file is part of the SwiftAndroidNative open source project diff --git a/Sources/AndroidChoreographer/AndroidChoreographer.swift b/Sources/AndroidChoreographer/AndroidChoreographer.swift index a32f9f4..0d21603 100644 --- a/Sources/AndroidChoreographer/AndroidChoreographer.swift +++ b/Sources/AndroidChoreographer/AndroidChoreographer.swift @@ -16,53 +16,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 + public func postFrameCallback(_ callback: @convention(c)(Int, UnsafeMutableRawPointer?) -> ()) { + AChoreographer_postFrameCallback(pointer, callback, nil) } } 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 diff --git a/Sources/AndroidContext/AndroidContext.swift b/Sources/AndroidContext/AndroidContext.swift index e266a09..d12cafb 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,24 @@ 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: { - let jvm: JavaVirtualMachine = try JavaVirtualMachine.shared() - let env: JNIEnvironment = try jvm.environment() + private static let applicationContext: Result = { + 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 if let contextPointer = contextPointer { - return AndroidContext(pointer: contextPointer, env: env) + return .success(AndroidContext(pointer: contextPointer, env: env)) } // alternative fallback mechanism: @@ -109,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 { - throw ContextError(errorDescription: "Invalid contextFactory signature: \(contextFactory)") + return .failure(.invalidSignature(contextFactory)) } let contextMethod = "" + contextFunctionParts[0] @@ -119,19 +131,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 +158,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 +178,4 @@ public class AndroidContext: @unchecked Sendable { jni.ReleaseStringUTFChars(env, javaString, utf8Chars) return result } - - struct ContextError: LocalizedError { - var errorDescription: String? - } } diff --git a/Sources/AndroidContext/Error.swift b/Sources/AndroidContext/Error.swift new file mode 100644 index 0000000..05b966d --- /dev/null +++ b/Sources/AndroidContext/Error.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftJavaJNICore + +public enum AndroidContextError: Error { + + case classNotFound(String) + case methodNotFound(String) + case nullValueForMethod(String) + case invalidSignature(String) + case virtualMachine(JavaVirtualMachine.VMError) +} 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/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 + } +} 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/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 +} diff --git a/Sources/AndroidLooper/Extensions/Duration.swift b/Sources/AndroidLooper/Extensions/Duration.swift new file mode 100644 index 0000000..cf879eb --- /dev/null +++ b/Sources/AndroidLooper/Extensions/Duration.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@available(macOS 13.0, *) +internal extension Duration { + + var milliseconds: Double { + Double(components.seconds) * 1000 + Double(components.attoseconds) * 1e-15 + } +} diff --git a/Sources/AndroidLooper/Extensions/OptionSet.swift b/Sources/AndroidLooper/Extensions/OptionSet.swift new file mode 100644 index 0000000..460a32d --- /dev/null +++ b/Sources/AndroidLooper/Extensions/OptionSet.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +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 + } +} 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 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 + ) + ) + } + } +} 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 } +} 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 } +} 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 +} 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 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 +} 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 diff --git a/Sources/AndroidSystem/LockedState.swift b/Sources/AndroidSystem/LockedState.swift index b740528..864a182 100644 --- a/Sources/AndroidSystem/LockedState.swift +++ b/Sources/AndroidSystem/LockedState.swift @@ -27,134 +27,134 @@ import Musl 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 - } +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 } - private class _Buffer: ManagedBuffer { - deinit { - withUnsafeMutablePointerToElements { - _Lock.deinitialize($0) - } - } + fileprivate static func deinitialize(_ platformLock: PlatformLock) { + #if canImport(Bionic) || canImport(Glibc) + pthread_mutex_destroy(platformLock) + #endif + platformLock.deinitialize(count: 1) } - private let _buffer: ManagedBuffer - - public init(initialState: State) { - _buffer = _Buffer.create(minimumCapacity: 1, makingHeaderWith: { buf in - buf.withUnsafeMutablePointerToElements { - _Lock.initialize($0) - } - return initialState - }) + 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 } - public func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { - try withLockUnchecked(body) + 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 } + } - 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) - } + private class _Buffer: ManagedBuffer { + deinit { + withUnsafeMutablePointerToElements { + _Lock.deinitialize($0) + } } + } + + private let _buffer: ManagedBuffer - // 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) - } + 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 } + } } -public extension LockedState where State == () { - init() { - self.init(initialState: ()) - } +extension LockedState where State == Void { + package init() { + self.init(initialState: ()) + } - func withLock(_ body: @Sendable () throws -> R) rethrows -> R { - try withLock { _ in - try body() - } - } + 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() + } - func lock() { - _buffer.withUnsafeMutablePointerToElements { lock in - _Lock.lock(lock) - } + package func lock() { + _buffer.withUnsafeMutablePointerToElements { lock in + _Lock.lock(lock) } + } - func unlock() { - _buffer.withUnsafeMutablePointerToElements { lock in - _Lock.unlock(lock) - } + package func unlock() { + _buffer.withUnsafeMutablePointerToElements { lock in + _Lock.unlock(lock) } + } } extension LockedState: @unchecked Sendable where State: Sendable {} diff --git a/Sources/AndroidSystem/SocketDescriptor.swift b/Sources/AndroidSystem/SocketDescriptor.swift new file mode 100644 index 0000000..13a3bf6 --- /dev/null +++ b/Sources/AndroidSystem/SocketDescriptor.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// 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 +} + +// 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 new file mode 100644 index 0000000..f0fbce3 --- /dev/null +++ b/Sources/AndroidSystem/SocketEvent.swift @@ -0,0 +1,230 @@ +//===----------------------------------------------------------------------===// +// +// 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_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_CLOEXEC) } + + /// 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 } +} + +// 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 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 diff --git a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift index c2d84e7..8e1e225 100644 --- a/Tests/AndroidLoggingTests/AndroidLoggingTests.swift +++ b/Tests/AndroidLoggingTests/AndroidLoggingTests.swift @@ -21,8 +21,8 @@ import AndroidLogging 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")