diff --git a/Package.swift b/Package.swift index 9a526131..06abde0e 100644 --- a/Package.swift +++ b/Package.swift @@ -78,6 +78,9 @@ var package = Package( ), .library( name: "AndroidHardware", targets: ["AndroidHardware"] + ), + .library( + name: "AndroidFileManager", targets: ["AndroidFileManager"] ) ], dependencies: [ @@ -149,7 +152,8 @@ var package = Package( "AndroidWebKit", "AndroidLogging", "AndroidLooper", - "AndroidHardware" + "AndroidHardware", + "AndroidFileManager" ], swiftSettings: [ .swiftLanguageMode(.v5), @@ -293,6 +297,8 @@ var package = Package( "AndroidJava", "AndroidUtil", "AndroidOS", + "AndroidNDK", + "AndroidFileManager", ], exclude: ["swift-java.config"], swiftSettings: [ @@ -467,6 +473,20 @@ var package = Package( linkerSettings: [ .linkedLibrary("android", .when(platforms: [.android])) ] + ), + .target( + name: "AndroidFileManager", + dependencies: [ + "AndroidNDK" + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ndkVersionDefine, + sdkVersionDefine + ], + linkerSettings: [ + .linkedLibrary("android", .when(platforms: [.android])) + ] ) ], swiftLanguageModes: [.v5, .v6] diff --git a/Sources/AndroidContent/AssetManagerNDK.swift b/Sources/AndroidContent/AssetManagerNDK.swift new file mode 100644 index 00000000..1e873242 --- /dev/null +++ b/Sources/AndroidContent/AssetManagerNDK.swift @@ -0,0 +1,55 @@ +// +// AssetManagerNDK.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if canImport(Android) +import Android +import AndroidNDK +#endif +import SwiftJava +import CSwiftJavaJNI +import AndroidFileManager + +// MARK: - NDK Asset Manager + +public extension AssetManager { + + /// Equivalent NDK type. + typealias NDK = AndroidFileManager.AssetManager + + /// Create a temporary NDK object and perform operations on it. + func withNDK(_ body: (borrowing NDK) throws(E) -> Result) throws(E) -> Result where E: Error { + let ndk = NDK.fromJava(javaThis, environment: javaEnvironment) + return try body(ndk) + } +} + +internal extension AndroidFileManager.AssetManager { + + /** + * Converts an android.content.res.AssetManager object into an AAssetManager* object. + * + * If the asset manager is null, null is returned. + * + * Available since API level 24. + * + * \param env Java environment. Must not be null. + * \param assetManager android.content.res.AssetManager java object. + * + * \return an AAssetManager object representing the Java AssetManager object. If either parameter + * is null, this will return null. + */ + static func fromJava(_ javaObject: jobject, environment: JNIEnvironment) -> AndroidFileManager.AssetManager { + guard let pointer = AAssetManager_fromJava(environment, javaObject) else { + fatalError("Unable to initialize from Java object") + } + return AndroidFileManager.AssetManager(pointer) + } +} + +#if !os(Android) +func AAssetManager_fromJava(_ environment: JNIEnvironment?, _ javaObject: jobject) -> OpaquePointer? { fatalError("stub") } +#endif diff --git a/Sources/AndroidFileManager/AndroidFileManager.swift b/Sources/AndroidFileManager/AndroidFileManager.swift new file mode 100644 index 00000000..7058582d --- /dev/null +++ b/Sources/AndroidFileManager/AndroidFileManager.swift @@ -0,0 +1,12 @@ +// +// AndroidFileManager.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import AndroidNDK +#endif + diff --git a/Sources/AndroidFileManager/Asset.swift b/Sources/AndroidFileManager/Asset.swift new file mode 100644 index 00000000..4f6b739d --- /dev/null +++ b/Sources/AndroidFileManager/Asset.swift @@ -0,0 +1,124 @@ +// +// Asset.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import AndroidNDK +#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) + } +} diff --git a/Sources/AndroidFileManager/AssetManager.swift b/Sources/AndroidFileManager/AssetManager.swift new file mode 100644 index 00000000..1e58bd60 --- /dev/null +++ b/Sources/AndroidFileManager/AssetManager.swift @@ -0,0 +1,55 @@ +// +// AssetManager.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import AndroidNDK +#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 + } +} + diff --git a/Sources/AndroidFileManager/Configuration.swift b/Sources/AndroidFileManager/Configuration.swift new file mode 100644 index 00000000..cd58413b --- /dev/null +++ b/Sources/AndroidFileManager/Configuration.swift @@ -0,0 +1,123 @@ +// +// Configuration.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import AndroidNDK +#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) + } +} diff --git a/Sources/AndroidFileManager/Error.swift b/Sources/AndroidFileManager/Error.swift new file mode 100644 index 00000000..261ba2ea --- /dev/null +++ b/Sources/AndroidFileManager/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) +} diff --git a/Sources/AndroidFileManager/StorageManager.swift b/Sources/AndroidFileManager/StorageManager.swift new file mode 100644 index 00000000..93e81eac --- /dev/null +++ b/Sources/AndroidFileManager/StorageManager.swift @@ -0,0 +1,80 @@ +// +// StorageManager.swift +// SwiftAndroid +// +// Created by Alsey Coleman Miller on 2/27/26. +// + +#if os(Android) +import Android +import AndroidNDK +#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) + } + } +} diff --git a/Sources/AndroidFileManager/Syscalls.swift b/Sources/AndroidFileManager/Syscalls.swift new file mode 100644 index 00000000..b50c94ea --- /dev/null +++ b/Sources/AndroidFileManager/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 diff --git a/Sources/AndroidKit/AndroidKit.swift b/Sources/AndroidKit/AndroidKit.swift index 752d5fc2..a244c9ef 100644 --- a/Sources/AndroidKit/AndroidKit.swift +++ b/Sources/AndroidKit/AndroidKit.swift @@ -31,3 +31,4 @@ import AndroidNDK @_exported import AndroidWebKit @_exported import AndroidLogging @_exported import AndroidLooper +@_exported import AndroidFileManager