From 5b9e79d40bb231ebe55f88944ddcc987e2df8d35 Mon Sep 17 00:00:00 2001 From: Yaroslav Biletskyi Date: Wed, 10 Jun 2026 13:40:48 +0300 Subject: [PATCH] Dev: migrate JavaCoder to swift-java-jni-core via java_swift shim Replace the readdle/java_swift dependency with swiftlang's official swift-java-jni-core (SwiftJavaJNICore), keeping JavaCoder's source effectively unchanged. --- Package.swift | 35 ++- .../{ => JavaCoder}/JNIArgumentProtocol.swift | 0 .../{ => JavaCoder}/JNICore+JavaCoder.swift | 0 .../{ => JavaCoder}/JNIObject+JavaCoder.swift | 0 Sources/JavaCoder/JavaBridgeable.swift | 244 ++++++++++++++++++ Sources/{ => JavaCoder}/JavaCoderConfig.swift | 1 - Sources/{ => JavaCoder}/JavaCodingError.swift | 0 Sources/{ => JavaCoder}/JavaDecoder.swift | 0 Sources/{ => JavaCoder}/JavaEncoder.swift | 0 Sources/{ => JavaCoder}/JavaPrimitive.swift | 1 - Sources/java_swift/JNIBootstrap.swift | 76 ++++++ Sources/java_swift/JNICore.swift | 198 ++++++++++++++ Sources/java_swift/JNIObject.swift | 144 +++++++++++ 13 files changed, 689 insertions(+), 10 deletions(-) rename Sources/{ => JavaCoder}/JNIArgumentProtocol.swift (100%) rename Sources/{ => JavaCoder}/JNICore+JavaCoder.swift (100%) rename Sources/{ => JavaCoder}/JNIObject+JavaCoder.swift (100%) create mode 100644 Sources/JavaCoder/JavaBridgeable.swift rename Sources/{ => JavaCoder}/JavaCoderConfig.swift (99%) rename Sources/{ => JavaCoder}/JavaCodingError.swift (100%) rename Sources/{ => JavaCoder}/JavaDecoder.swift (100%) rename Sources/{ => JavaCoder}/JavaEncoder.swift (100%) rename Sources/{ => JavaCoder}/JavaPrimitive.swift (99%) create mode 100644 Sources/java_swift/JNIBootstrap.swift create mode 100644 Sources/java_swift/JNICore.swift create mode 100644 Sources/java_swift/JNIObject.swift diff --git a/Package.swift b/Package.swift index 471f613..c9367a8 100755 --- a/Package.swift +++ b/Package.swift @@ -1,21 +1,40 @@ -// swift-tools-version:4.0 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "JavaCoder", - products:[ + products: [ .library( - name: "JavaCoder", - targets:["JavaCoder"] - ) + name: "JavaCoder", + targets: ["JavaCoder"] + ), + .library( + name: "java_swift", + targets: ["java_swift"] + ), ], dependencies: [ - .package(url: "https://github.com/readdle/java_swift.git", .upToNextMinor(from: "2.2.0")), + .package(url: "https://github.com/swiftlang/swift-java-jni-core.git", .upToNextMinor(from: "0.5.1")), .package(url: "https://github.com/readdle/swift-anycodable.git", .upToNextMinor(from: "1.0.2")), ], targets: [ - .target(name: "JavaCoder", dependencies: ["java_swift", "AnyCodable"], path: "Sources"), + // compatibility shim exposing the historical `java_swift` API on top of swiftlang's SwiftJavaJNICore (jni-core). + .target( + name: "java_swift", + dependencies: [ + .product(name: "SwiftJavaJNICore", package: "swift-java-jni-core"), + ], + path: "Sources/java_swift" + ), + .target( + name: "JavaCoder", + dependencies: [ + "java_swift", + .product(name: "AnyCodable", package: "swift-anycodable"), + ], + path: "Sources/JavaCoder" + ), ], - swiftLanguageVersions: [5, 4] + swiftLanguageModes: [.v5] ) diff --git a/Sources/JNIArgumentProtocol.swift b/Sources/JavaCoder/JNIArgumentProtocol.swift similarity index 100% rename from Sources/JNIArgumentProtocol.swift rename to Sources/JavaCoder/JNIArgumentProtocol.swift diff --git a/Sources/JNICore+JavaCoder.swift b/Sources/JavaCoder/JNICore+JavaCoder.swift similarity index 100% rename from Sources/JNICore+JavaCoder.swift rename to Sources/JavaCoder/JNICore+JavaCoder.swift diff --git a/Sources/JNIObject+JavaCoder.swift b/Sources/JavaCoder/JNIObject+JavaCoder.swift similarity index 100% rename from Sources/JNIObject+JavaCoder.swift rename to Sources/JavaCoder/JNIObject+JavaCoder.swift diff --git a/Sources/JavaCoder/JavaBridgeable.swift b/Sources/JavaCoder/JavaBridgeable.swift new file mode 100644 index 0000000..a952b83 --- /dev/null +++ b/Sources/JavaCoder/JavaBridgeable.swift @@ -0,0 +1,244 @@ +// +// JavaBridgeable.swift +// +// Ported from readdle/swift-java's `Java` module (JavaBridgeable.swift) and +// readdle/java_swift (Throwable.swift) so that the JavaCoder package provides +// the non-primitive bridging the SwiftJava codegen relies on, without the +// readdle `Java`/`java_swift` runtime dependencies. +// +// This lives in the JavaCoder target (rather than the `java_swift` shim) +// because the default `from(javaObject:)`/`javaObject()` implementations are +// built on `JavaDecoder`/`JavaEncoder`. The shim cannot import JavaCoder +// without forming a dependency cycle. +// + +import Foundation +import java_swift + +public protocol JavaBridgeable: Codable { + + static func from(javaObject: jobject) throws -> Self + + func javaObject() throws -> jobject +} + +extension JavaBridgeable { + + // Decoding SwiftValue type with JavaCoder + public static func from(javaObject: jobject) throws -> Self { + // ignore forPackage for basic impl + return try JavaDecoder(forPackage: "").decode(Self.self, from: javaObject) + } + + // Encoding SwiftValue type with JavaCoder + public func javaObject() throws -> jobject { + // ignore forPackage for basic impl + return try JavaEncoder(forPackage: "").encode(self) + } + +} + +extension Bool: JavaBridgeable {} +extension Int: JavaBridgeable {} +extension Int8: JavaBridgeable {} +extension Int16: JavaBridgeable {} +extension Int32: JavaBridgeable {} +extension Int64: JavaBridgeable {} +extension UInt: JavaBridgeable {} +extension UInt8: JavaBridgeable {} +extension UInt16: JavaBridgeable {} +extension UInt32: JavaBridgeable {} +extension UInt64: JavaBridgeable {} +extension Float: JavaBridgeable {} +extension Double: JavaBridgeable {} +extension Dictionary: JavaBridgeable where Key: Codable, Value: Codable {} +extension Set: JavaBridgeable where Element: Codable {} +extension Date: JavaBridgeable {} +extension Data: JavaBridgeable {} +extension URL: JavaBridgeable {} + +extension String: JavaBridgeable { + + public static func from(javaObject: jobject) throws -> String { + var isCopy: jboolean = 0 + guard let env = JNI.env, + let chars = JNI.api.GetStringChars(env, javaObject, &isCopy) else { + throw JavaCodingError.cantCreateObject("String") + } + defer { + JNI.api.ReleaseStringChars(env, javaObject, chars) + } + return String(utf16CodeUnits: chars, count: Int(JNI.api.GetStringLength(env, javaObject))) + } + + public func javaObject() throws -> jobject { + // Use NewString with UTF-16 code units rather than NewStringUTF, which + // expects Java's "modified UTF-8" and mangles supplementary (4-byte) + // characters. Matches the GetStringChars decode path above. + guard let env = JNI.env else { + throw JavaCodingError.cantCreateObject("String") + } + let utf16 = Array(self.utf16) + guard let javaObject: jstring = utf16.withUnsafeBufferPointer({ buffer in + JNI.api.NewString(env, buffer.baseAddress, jsize(buffer.count)) + }) else { + throw JavaCodingError.cantCreateObject("String") + } + return javaObject + } + +} + +/// Minimal `java.lang.Throwable` wrapper, ported from readdle/java_swift, used +/// to bridge Java exceptions into `NSError`. +open class Throwable { + + private let javaObject: jobject + + public required init(javaObject: jobject) { + self.javaObject = javaObject + } + + open func getMessage() -> String! { + guard let methodID = try? JNI.getJavaMethod(forClass: "java/lang/Throwable", + method: "getMessage", + sig: "()Ljava/lang/String;"), + let result = JNI.CallObjectMethod(javaObject, methodID: methodID) else { + return nil + } + defer { + JNI.DeleteLocalRef(result) + } + return String(javaObject: result) + } + + open func printStackTrace() { + guard let methodID = try? JNI.getJavaMethod(forClass: "java/lang/Throwable", + method: "printStackTrace", + sig: "()V") else { + return + } + JNI.CallVoidMethod(javaObject, methodID) + } + + public func className() -> String { + guard let env = JNI.env, + let cls = JNI.api.GetObjectClass(env, javaObject) else { + return "java/lang/Throwable" + } + defer { + JNI.DeleteLocalRef(cls) + } + guard let methodID = try? JNI.getJavaMethod(forClass: "java/lang/Class", + method: "getName", + sig: "()Ljava/lang/String;"), + let javaClassName = JNI.CallObjectMethod(cls, methodID: methodID) else { + return "java/lang/Throwable" + } + defer { + JNI.DeleteLocalRef(javaClassName) + } + return String(javaObject: javaClassName) + } + + public func stackTraceString() -> String { + return "" // no stack trace here + } + + public func lastStackTraceString() -> String? { + return nil + } + +} + +// Error can't implement JavaBridgeable protocol +fileprivate let javaExceptionClass = JNI.GlobalFindClass("java/lang/Exception")! +fileprivate let javaExceptionConstructor = try! JNI.getJavaMethod(forClass: "java/lang/Exception", + method: "", + sig: "(Ljava/lang/String;)V") + +fileprivate let JavaErrorMessageKey = "JavaErrorMessageKey" +fileprivate let JavaErrorStackTrace = "JavaErrorStackTrace" + +extension Error { + + public static var javaErrorMessageKey: String { + return JavaErrorMessageKey + } + + public static var javaErrorStackTrace: String { + return JavaErrorStackTrace + } + + public static func from(javaObject: jobject) throws -> Error { + let throwable = Throwable(javaObject: javaObject) + let className = throwable.className() + let message = throwable.getMessage() + let lastStackTrace = throwable.lastStackTraceString() + let userInfo: [String: Any] = [javaErrorMessageKey: message ?? "unavailable", + javaErrorStackTrace: lastStackTrace ?? "unavailable"] + + // Try extract error according to Error.javaObject() + if let javaMessage = message { + let parts = javaMessage.split(separator: ":") + if parts.count > 1 { + let domain = String(parts[0]) + let codeString = String(parts[1]) + if let code = Int(codeString) { + return NSError(domain: domain, code: code, userInfo: userInfo) + } + } + } + + // Plan B + let domain = className + let code = 0 + return NSError(domain: domain, code: code, userInfo: userInfo) + } + +} + +#if !HIDE_ERROR_JAVA_BRIDGEABLE +extension Error { + public func javaObject() throws -> jobject { + let nsError = self as NSError + let message = "\(nsError.domain):\(nsError.code)" + guard let javaObject = JNI.NewObject(javaExceptionClass, + methodID: javaExceptionConstructor, + args: [jvalue(l: try message.javaObject())]) else { + throw JavaCodingError.cantCreateObject("java/lang/Exception") + } + return javaObject + } +} + +extension Error where Self: RawRepresentable, Self.RawValue: SignedInteger { + + public func javaObject() throws -> jobject { + let domain = String(reflecting: type(of: self)) + let code: Int = numericCast(self.rawValue) + let message = try "\(domain):\(code)".javaObject() + guard let javaObject = JNI.NewObject(javaExceptionClass, + methodID: javaExceptionConstructor, + args: [jvalue(l: message)]) else { + throw JavaCodingError.cantCreateObject("java/lang/Exception") + } + return javaObject + } +} + +extension Error where Self: RawRepresentable, Self.RawValue: UnsignedInteger { + + public func javaObject() throws -> jobject { + let domain = String(reflecting: type(of: self)) + let code: Int = numericCast(self.rawValue) + let message = try "\(domain):\(code)".javaObject() + guard let javaObject = JNI.NewObject(javaExceptionClass, + methodID: javaExceptionConstructor, + args: [jvalue(l: message)]) else { + throw JavaCodingError.cantCreateObject("java/lang/Exception") + } + return javaObject + } +} +#endif diff --git a/Sources/JavaCoderConfig.swift b/Sources/JavaCoder/JavaCoderConfig.swift similarity index 99% rename from Sources/JavaCoderConfig.swift rename to Sources/JavaCoder/JavaCoderConfig.swift index c24ffed..c2a1855 100644 --- a/Sources/JavaCoderConfig.swift +++ b/Sources/JavaCoder/JavaCoderConfig.swift @@ -4,7 +4,6 @@ import Foundation import java_swift -import CAndroidNDK public typealias JavaEncodableClosure = (Any, [CodingKey]) throws -> jobject public typealias JavaDecodableClosure = (jobject, [CodingKey]) throws -> Decodable diff --git a/Sources/JavaCodingError.swift b/Sources/JavaCoder/JavaCodingError.swift similarity index 100% rename from Sources/JavaCodingError.swift rename to Sources/JavaCoder/JavaCodingError.swift diff --git a/Sources/JavaDecoder.swift b/Sources/JavaCoder/JavaDecoder.swift similarity index 100% rename from Sources/JavaDecoder.swift rename to Sources/JavaCoder/JavaDecoder.swift diff --git a/Sources/JavaEncoder.swift b/Sources/JavaCoder/JavaEncoder.swift similarity index 100% rename from Sources/JavaEncoder.swift rename to Sources/JavaCoder/JavaEncoder.swift diff --git a/Sources/JavaPrimitive.swift b/Sources/JavaCoder/JavaPrimitive.swift similarity index 99% rename from Sources/JavaPrimitive.swift rename to Sources/JavaCoder/JavaPrimitive.swift index f5c98c7..b36d81d 100644 --- a/Sources/JavaPrimitive.swift +++ b/Sources/JavaCoder/JavaPrimitive.swift @@ -4,7 +4,6 @@ import Foundation import java_swift -import CAndroidNDK public typealias JavaBoolean = jboolean public typealias JavaByte = jbyte diff --git a/Sources/java_swift/JNIBootstrap.swift b/Sources/java_swift/JNIBootstrap.swift new file mode 100644 index 0000000..7a02c0e --- /dev/null +++ b/Sources/java_swift/JNIBootstrap.swift @@ -0,0 +1,76 @@ +// +// JNIBootstrap.swift +// java_swift (compatibility shim) +// +// JVM bootstrap glue. +// +// IMPORTANT: This shim intentionally does NOT define `@_cdecl("JNI_OnLoad")`. +// A dynamic library can export only one `JNI_OnLoad`, and when this package is +// linked alongside swiftlang's `SwiftJava` (which defines its own `JNI_OnLoad` +// that calls `JavaVirtualMachine.setSharedJVM`), defining a second one here +// would be a duplicate-symbol conflict. +// +// Registering the JVM is the application's responsibility. Define your own +// `@_cdecl("JNI_OnLoad")` (or rely on SwiftJava's, when linked in) to register +// the shared JVM via `JavaVirtualMachine.setShared(...)` / +// `JavaVirtualMachine.setSharedJVM(...)`. Once a shared JVM is registered, +// `JNI.env` / `JavaVirtualMachine.shared()` work out of the box. +// +// The only thing this shim needs beyond that is the context class loader, used +// to resolve *application* classes from threads not started by the JVM +// (notably on Android). Call `JNIBootstrap.captureContextClassLoader()` once +// from a Java-originated thread (e.g. your native init method) to capture it. +// +// On desktop where Swift drives the JVM, no bootstrap is required; +// `JavaVirtualMachine.shared()` lazily creates or adopts a JVM, and raw +// `FindClass` resolves classpath classes without a context loader. +// + +import Foundation +@_exported import SwiftJavaJNICore + +public enum JNIBootstrap { + + /// Capture the current thread's context class loader into ``JNICore``'s + /// ``JNICore/classLoader`` so that ``JNICore/FindClass(_:_:_:)`` can resolve + /// application classes from threads not started by the JVM. + /// + /// Call this exactly once, early, from a thread that originated in Java + /// (so the context class loader is the application loader, not the system + /// loader). Safe to use whether or not SwiftJava is present; it relies only + /// on the shared JVM already being registered. + /// + /// - Returns: `true` if a class loader was captured. + @discardableResult + public static func captureContextClassLoader() -> Bool { + guard let env = JNI.env else { + return false + } + guard let threadClass = JNI.api.FindClass(env, "java/lang/Thread") else { + return false + } + defer { env.deleteLocalRef(threadClass) } + + guard let currentThreadMethod = JNI.api.GetStaticMethodID(env, threadClass, + "currentThread", + "()Ljava/lang/Thread;"), + let getContextClassLoaderMethod = JNI.api.GetMethodID(env, threadClass, + "getContextClassLoader", + "()Ljava/lang/ClassLoader;") else { + return false + } + + guard let currentThread = JNI.api.CallStaticObjectMethodA(env, threadClass, currentThreadMethod, nil) else { + return false + } + defer { env.deleteLocalRef(currentThread) } + + guard let loader = JNI.api.CallObjectMethodA(env, currentThread, getContextClassLoaderMethod, nil) else { + return false + } + defer { env.deleteLocalRef(loader) } + + JNI.classLoader = JNI.api.NewGlobalRef(env, loader) + return JNI.classLoader != nil + } +} diff --git a/Sources/java_swift/JNICore.swift b/Sources/java_swift/JNICore.swift new file mode 100644 index 0000000..c379757 --- /dev/null +++ b/Sources/java_swift/JNICore.swift @@ -0,0 +1,198 @@ +// +// JNICore.swift +// java_swift (compatibility shim) +// +// Drop-in replacement for the subset of the readdle/java_swift `JNICore` +// API used by JavaCoder, re-implemented on top of swiftlang's +// SwiftJavaJNICore (jni-core). +// +// The original library exposed a global `JNI` instance of `JNICore` that +// vended the raw JNI function table (`JNI.api`) and the current thread's +// environment pointer (`JNI.env`). jni-core models the same concepts via +// `JavaVirtualMachine.shared().environment()` and `env.interface`, so this +// file simply re-shapes those into the historical surface. +// + +import Foundation +@_exported import SwiftJavaJNICore + +/// Global JNI access point, matching the historical `java_swift` API. +public let JNI = JNICore() + +open class JNICore { + + /// Context class loader captured at `JNI_OnLoad` time. Used by + /// ``FindClass(_:_:_:)`` to resolve application classes from threads that + /// were not started by the JVM (notably on Android). + open var classLoader: jclass! + + /// Logger used for diagnostics. Mirrors the original library's hook. + open var errorLogger: (_ message: String) -> Void = { message in + NSLog("%@", message) + } + + public init() {} + + // MARK: Environment / raw interface + + /// The JNI environment for the current thread, attaching it to the JVM if + /// necessary. Returns `nil` if no JVM is available. + open var env: JNIEnvironment? { + guard let vm = try? JavaVirtualMachine.shared() else { + return nil + } + return try? vm.environment() + } + + /// The raw JNI function table (`JNINativeInterface`) for the current thread. + /// + /// Force-unwraps the environment to match the historical non-optional + /// `JNI.api` shape; call sites always have a live JVM by the time they + /// reach JNI calls. + open var api: JNINativeInterface { + return env!.interface + } + + // MARK: Diagnostics + + open func report(_ msg: String, _ file: StaticString = #file, _ line: Int = #line) { + errorLogger("\(msg) - at \(file):\(line)") + } + + // MARK: Class lookup + + private var loadClassMethodID: jmethodID? + + /// Find a Java class by its JNI name (e.g. `java/lang/Integer`). + /// + /// When a ``classLoader`` was captured, classes are resolved through + /// `ClassLoader.loadClass(String)` so that application classes are found + /// from arbitrary threads. Otherwise this falls back to the raw + /// `FindClass` JNI call. + open func FindClass(_ name: UnsafePointer, + _ file: StaticString = #file, + _ line: Int = #line) -> jclass? { + ExceptionReset() + guard let env = self.env else { + return nil + } + let className = String(cString: name) + + if let classLoader = self.classLoader { + var locals = [jobject]() + let javaName = className.localJavaObject(&locals) + if loadClassMethodID == nil, let clClass = api.GetObjectClass(env, classLoader) { + loadClassMethodID = api.GetMethodID(env, clClass, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;") + api.DeleteLocalRef(env, clClass) + } + let args = [jvalue(l: javaName)] + let clazz: jclass? = args.withUnsafeBufferPointer { ptr in + api.CallObjectMethodA(env, classLoader, loadClassMethodID, ptr.baseAddress) + } + for local in locals { + DeleteLocalRef(local) + } + if clazz == nil { + report("Could not find class \(className)", file, line) + } + return clazz + } else { + let clazz = api.FindClass(env, name) + if clazz == nil { + report("Could not find class \(className)", file, line) + } + return clazz + } + } + + // MARK: Reference management + + open func DeleteLocalRef(_ local: jobject?) { + if let local = local, let env = self.env { + env.deleteLocalRef(local) + } + } + + /// Delete the supplied local references and drain any pending Java + /// exception into the thread-local cache so it can later be retrieved via + /// ``ExceptionCheck()``. Returns `result` unchanged for call-site + /// convenience. + @discardableResult + open func check(_ result: T, + _ locals: UnsafeMutablePointer<[jobject]>, + removeLast: Bool = false, + _ file: StaticString = #file, + _ line: Int = #line) -> T { + if removeLast && locals.pointee.count != 0 { + locals.pointee.removeLast() + } + for local in locals.pointee { + DeleteLocalRef(local) + } + if let env = self.env, api.ExceptionCheck(env) != 0 { + if let throwable = api.ExceptionOccurred(env) { + setThrown(throwable) + api.ExceptionClear(env) + } + } + return result + } + + // MARK: Exceptions + + /// Returns and clears the pending exception captured for the current + /// thread, if any. + open func ExceptionCheck() -> jthrowable? { + return takeThrown() + } + + /// Logs and clears any left-over exception captured for the current thread. + open func ExceptionReset() { + if let env = self.env, api.ExceptionCheck(env) != 0 { + if let throwable = api.ExceptionOccurred(env) { + setThrown(throwable) + api.ExceptionClear(env) + } + } + if ExceptionCheck() != nil { + errorLogger("Left over exception") + } + } + + // MARK: Fatal error message (thread-local breadcrumb) + + open func SaveFatalErrorMessage(_ msg: String, + _ file: StaticString = #file, + _ line: Int = #line) { + Thread.current.threadDictionary[JNICore.fatalKey] = "\(msg) at \(file):\(line)" + } + + open func RemoveFatalErrorMessage() { + Thread.current.threadDictionary.removeObject(forKey: JNICore.fatalKey) + } + + open func GetFatalErrorMessage() -> String? { + return Thread.current.threadDictionary[JNICore.fatalKey] as? String + } + + // MARK: Thread-local storage + + private static let thrownKey = "com.readdle.java_swift.thrown" + private static let fatalKey = "com.readdle.java_swift.fatal" + + private func setThrown(_ throwable: jthrowable) { + Thread.current.threadDictionary[JNICore.thrownKey] = UInt(bitPattern: throwable) + } + + private func takeThrown() -> jthrowable? { + let dict = Thread.current.threadDictionary + guard let bits = dict[JNICore.thrownKey] as? UInt, + let throwable = UnsafeMutableRawPointer(bitPattern: bits) else { + return nil + } + dict.removeObject(forKey: JNICore.thrownKey) + return throwable + } +} diff --git a/Sources/java_swift/JNIObject.swift b/Sources/java_swift/JNIObject.swift new file mode 100644 index 0000000..cac2694 --- /dev/null +++ b/Sources/java_swift/JNIObject.swift @@ -0,0 +1,144 @@ +// +// JNIObject.swift +// java_swift (compatibility shim) +// +// Faithful port of the readdle/java_swift `JNIObject` hierarchy and the +// `String` <-> Java bridging helpers that JavaCoder depends on, backed by +// SwiftJavaJNICore. +// + +import Foundation +@_exported import SwiftJavaJNICore + +public protocol JNIObjectProtocol { + func localJavaObject(_ locals: UnsafeMutablePointer<[jobject]>) -> jobject? +} + +public protocol JNIObjectInit { + init(javaObject: jobject?) +} + +extension JNIObjectProtocol { + public func withJavaObject(_ body: @escaping (jobject?) throws -> Result) rethrows -> Result { + var locals = [jobject]() + let javaObject: jobject? = localJavaObject(&locals) + defer { + for local in locals { + JNI.DeleteLocalRef(local) + } + } + return try body(javaObject) + } +} + +public protocol JavaProtocol: JNIObjectProtocol {} + +/// A Swift handle to a Java object. The underlying reference is promoted to a +/// JNI global reference so it remains valid across native calls and threads, +/// matching the original library's lifetime semantics. +open class JNIObject: JNIObjectProtocol, JNIObjectInit { + + private var _javaObject: jobject? + + open var javaObject: jobject? { + get { + return _javaObject + } + set(newValue) { + if newValue != _javaObject { + let oldValue: jobject? = _javaObject + if let newValue = newValue, let env = JNI.env { + _javaObject = JNI.api.NewGlobalRef(env, newValue) + } else { + _javaObject = nil + } + if let oldValue = oldValue, let env = JNI.env { + JNI.api.DeleteGlobalRef(env, oldValue) + } + } + } + } + + public required init(javaObject: jobject?) { + self.javaObject = javaObject + } + + public convenience init() { + self.init(javaObject: nil) + } + + open var isNull: Bool { + guard let env = JNI.env else { + return _javaObject == nil + } + return _javaObject == nil || JNI.api.IsSameObject(env, _javaObject, nil) == jboolean(JNI_TRUE) + } + + open func localJavaObject(_ locals: UnsafeMutablePointer<[jobject]>) -> jobject? { + guard let javaObject = _javaObject, let env = JNI.env else { + return nil + } + if let local: jobject = JNI.api.NewLocalRef(env, javaObject) { + locals.pointee.append(local) + return local + } + return nil + } + + open func clearLocal() {} + + deinit { + javaObject = nil + } +} + +open class JNIObjectForward: JNIObject {} + +// MARK: - String bridging + +extension String: JNIObjectProtocol { + public func localJavaObject(_ locals: UnsafeMutablePointer<[jobject]>) -> jobject? { + guard let env = JNI.env else { + return nil + } + // Delegate to jni-core's `JavaValue` conformance, which builds the + // jstring via `NewString` over UTF-16 code units. (We deliberately do + // NOT use jni-core's read path `String(fromJNI:)`, which decodes via + // `GetStringUTFChars` / modified UTF-8 and mangles supplementary + // 4-byte characters — see `init(javaObject:)` below.) + if let javaObject: jstring = self.getJNIValue(in: env) { + locals.pointee.append(javaObject) + return javaObject + } + return nil + } +} + +extension String: JNIObjectInit { + public init(javaObject: jobject?) { + var isCopy: jboolean = 0 + if let javaObject = javaObject, + let env = JNI.env, + let value: UnsafePointer = JNI.api.GetStringChars(env, javaObject, &isCopy) { + self.init(utf16CodeUnits: value, count: Int(JNI.api.GetStringLength(env, javaObject))) + JNI.api.ReleaseStringChars(env, javaObject, value) + } else { + self.init() + } + } +} + +// MARK: - Object array helpers + +extension jobject { + public func arrayMap(block: (_ javaObject: jobject?) -> T) -> [T] { + guard let env = JNI.env else { + return [] + } + return (0 ..< JNI.api.GetArrayLength(env, self)).map { index in + let element: jobject? = JNI.api.GetObjectArrayElement(env, self, index) + defer { JNI.DeleteLocalRef(element) } + return block(element) + } + } +}