diff --git a/Package.swift b/Package.swift index 14bd36aea..a4933d9f0 100644 --- a/Package.swift +++ b/Package.swift @@ -468,6 +468,7 @@ let package = Package( name: "SwiftJavaTests", dependencies: [ "SwiftJava", + "SwiftJavaRuntimeSupport", "JavaNet", ], swiftSettings: [ diff --git a/Sources/JExtractSwiftLib/Convenience/JavaType+Extensions.swift b/Sources/JExtractSwiftLib/Convenience/JavaType+Extensions.swift index 1c35cbc23..ed42023b0 100644 --- a/Sources/JExtractSwiftLib/Convenience/JavaType+Extensions.swift +++ b/Sources/JExtractSwiftLib/Convenience/JavaType+Extensions.swift @@ -60,13 +60,16 @@ extension JavaType { case .float: "Optional" case .double: "OptionalDouble" case .javaLangString: "Optional" + case .javaNetURI, .javaUtilDate, .javaUtilLocale, .javaUtilTimeZone, .array(.byte): "Optional<\(self)>" default: nil } } var optionalWrapperType: String? { switch self { - case .boolean, .byte, .char, .short, .float, .javaLangString: "Optional" + case .boolean, .byte, .char, .short, .float, .javaLangString, + .javaNetURI, .javaUtilDate, .javaUtilLocale, .javaUtilTimeZone, .array(.byte): + "Optional" case .int: "OptionalInt" case .long: "OptionalLong" case .double: "OptionalDouble" @@ -130,7 +133,8 @@ extension JavaType { /// Returns whether this type returns `JavaValue` from SwiftJava var implementsJavaValue: Bool { switch self { - case .boolean, .byte, .char, .short, .int, .long, .float, .double, .void, .javaLangString: + case .boolean, .byte, .char, .short, .int, .long, .float, .double, .void, .javaLangString, + .javaNetURI, .javaUtilDate, .javaUtilLocale, .javaUtilTimeZone: true case .array(let element): element.implementsJavaValue diff --git a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift index 2019ba48a..e122e39cb 100644 --- a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift @@ -42,20 +42,30 @@ enum JNIJavaTypeTranslator { case .string: return .javaLangString + case .foundationCGFloat: return .double + + case .foundationURL, .essentialsURL: + return config.effectiveFoundationTypesAsJavaTypes ? .javaNetURI : nil + case .foundationDate, .essentialsDate: + return config.effectiveFoundationTypesAsJavaTypes ? .javaUtilDate : nil + case .foundationData, .essentialsData: + return config.effectiveFoundationTypesAsJavaTypes ? .array(.byte) : nil + case .foundationLocale, .essentialsLocale: + return config.effectiveFoundationTypesAsJavaTypes ? .javaUtilLocale : nil + case .foundationTimeZone, .essentialsTimeZone: + return config.effectiveFoundationTypesAsJavaTypes ? .javaUtilTimeZone : nil + case .unsafeRawPointer, .unsafeMutableRawPointer, .unsafePointer, .unsafeMutablePointer, .unsafeRawBufferPointer, .unsafeMutableRawBufferPointer, .unsafeBufferPointer, .unsafeMutableBufferPointer, .opaquePointer, .optional, - .foundationData, .foundationDataProtocol, - .essentialsData, .essentialsDataProtocol, + .foundationDataProtocol, .essentialsDataProtocol, .array, .dictionary, .set, - .foundationDate, .essentialsDate, - .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationUUID, .essentialsUUID: return nil } } @@ -67,6 +77,7 @@ enum JNIJavaTypeTranslator { switch knownKind { case .int: knownTypes.int64 case .uint: knownTypes.uint64 + case .foundationCGFloat: knownTypes.double case .bool, .int8, .uint8, .int16, .uint16, .int32, .uint32, .int64, .uint64, .float, .double, .void, .string, @@ -83,7 +94,9 @@ enum JNIJavaTypeTranslator { .set, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationURL, .essentialsURL, + .foundationLocale, .essentialsLocale, + .foundationTimeZone, .essentialsTimeZone: nil } } @@ -111,7 +124,10 @@ enum JNIJavaTypeTranslator { .set, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationURL, .essentialsURL, + .foundationLocale, .essentialsLocale, + .foundationTimeZone, .essentialsTimeZone, + .foundationCGFloat: nil } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 9dc93d507..fa8580786 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -506,7 +506,12 @@ extension JNISwift2JavaGenerator { genericRequirements: genericRequirements, ) - case .foundationDate, .essentialsDate, .foundationData, .essentialsData, .foundationURL, .essentialsURL: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes, + .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes, + .foundationURL where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsURL where !config.effectiveFoundationTypesAsJavaTypes: break // Handled as wrapped struct case .unsafeRawBufferPointer, .unsafeMutableRawBufferPointer: @@ -851,11 +856,13 @@ extension JNISwift2JavaGenerator { if let knownType = nominalType.nominalTypeDecl.knownTypeKind { switch knownType { - case .foundationDate, .essentialsDate: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break - case .foundationData, .essentialsData: + case .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break @@ -971,7 +978,12 @@ extension JNISwift2JavaGenerator { genericRequirements: genericRequirements, ) - case .foundationDate, .essentialsDate, .foundationData, .essentialsData, .foundationURL, .essentialsURL: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes, + .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes, + .foundationURL where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsURL where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break @@ -1161,16 +1173,19 @@ extension JNISwift2JavaGenerator { ) return .swiftSet(elementJavaType) - case .foundationDate, .essentialsDate: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes: return .class(package: nil, name: "Date") - case .foundationData, .essentialsData: + case .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes: return .class(package: nil, name: "Data") case .foundationDataProtocol, .essentialsDataProtocol: return .class(package: nil, name: "DataProtocol") - case .foundationURL, .essentialsURL: + case .foundationURL where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsURL where !config.effectiveFoundationTypesAsJavaTypes: return .class(package: nil, name: "URL") case .foundationUUID, .essentialsUUID: diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 6490586d7..05358a85b 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -164,7 +164,12 @@ extension JNISwift2JavaGenerator { conversionCheck: nil ) - case .foundationDate, .essentialsDate, .foundationData, .essentialsData, .foundationURL, .essentialsURL: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes, + .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes, + .foundationURL where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsURL where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break @@ -536,11 +541,13 @@ extension JNISwift2JavaGenerator { if let knownType = nominalType.nominalTypeDecl.knownTypeKind { switch knownType { - case .foundationDate, .essentialsDate: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break - case .foundationData, .essentialsData: + case .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break @@ -818,7 +825,12 @@ extension JNISwift2JavaGenerator { resultName: resultName ) - case .foundationDate, .essentialsDate, .foundationData, .essentialsData, .foundationURL, .essentialsURL: + case .foundationDate where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsDate where !config.effectiveFoundationTypesAsJavaTypes, + .foundationData where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsData where !config.effectiveFoundationTypesAsJavaTypes, + .foundationURL where !config.effectiveFoundationTypesAsJavaTypes, + .essentialsURL where !config.effectiveFoundationTypesAsJavaTypes: // Handled as wrapped struct break diff --git a/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index a64ef4466..e5c63bb45 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -21,6 +21,7 @@ public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer { config: config, extractDecider: JavaExtractDecider( accessLevel: config.effectiveMinimumInputAccessLevelMode, + foundationTypesAsJavaTypes: config.effectiveMode == .jni && config.effectiveFoundationTypesAsJavaTypes, logLevel: config.logLevel ?? .info ), ) @@ -40,10 +41,17 @@ public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer { /// operator-overload syntax, so the generator can't render them public struct JavaExtractDecider: ExtractDecider { public let accessLevel: AccessLevelMode + /// Whether `Data`, `Date` and `URL` cross as Java types, so no wrapper type is extracted for them + public let foundationTypesAsJavaTypes: Bool let log: Logger - public init(accessLevel: AccessLevelMode = .default, logLevel: LogLevel = .info) { + public init( + accessLevel: AccessLevelMode = .default, + foundationTypesAsJavaTypes: Bool = false, + logLevel: LogLevel = .info + ) { self.accessLevel = accessLevel + self.foundationTypesAsJavaTypes = foundationTypesAsJavaTypes self.log = Logger(label: "JavaExtractDecider", logLevel: logLevel) } @@ -91,4 +99,13 @@ public struct JavaExtractDecider: ExtractDecider { } return ok } + + public func shouldExtractFoundationType(_ kind: SwiftKnownTypeDeclKind) -> Bool { + switch kind { + case .foundationData, .essentialsData, .foundationDate, .essentialsDate, .foundationURL, .essentialsURL: + !foundationTypesAsJavaTypes + default: + true + } + } } diff --git a/Sources/JExtractSwiftLib/JavaTypes/JavaType+JDK.swift b/Sources/JExtractSwiftLib/JavaTypes/JavaType+JDK.swift index 0a5743cc1..b6cddb167 100644 --- a/Sources/JExtractSwiftLib/JavaTypes/JavaType+JDK.swift +++ b/Sources/JExtractSwiftLib/JavaTypes/JavaType+JDK.swift @@ -174,4 +174,20 @@ extension JavaType { .class(package: "java.util", name: "UUID") } + static var javaNetURI: JavaType { + .class(package: "java.net", name: "URI") + } + + static var javaUtilDate: JavaType { + .class(package: "java.util", name: "Date") + } + + static var javaUtilLocale: JavaType { + .class(package: "java.util", name: "Locale") + } + + static var javaUtilTimeZone: JavaType { + .class(package: "java.util", name: "TimeZone") + } + } diff --git a/Sources/SwiftExtract/ExtractDecider.swift b/Sources/SwiftExtract/ExtractDecider.swift index 2e3a02302..2e3e22963 100644 --- a/Sources/SwiftExtract/ExtractDecider.swift +++ b/Sources/SwiftExtract/ExtractDecider.swift @@ -38,6 +38,16 @@ public protocol ExtractDecider { decl: DeclSyntax, in parent: ExtractedNominalType? ) -> Bool + + /// Whether a Foundation type the module uses, such as `Data`, `Date` or `URL`, is extracted as a type of + /// its own. Return `false` when the target language represents values of `kind` with one of its own types. + func shouldExtractFoundationType(_ kind: SwiftKnownTypeDeclKind) -> Bool +} + +extension ExtractDecider { + public func shouldExtractFoundationType(_ kind: SwiftKnownTypeDeclKind) -> Bool { + true + } } /// Minimal `ExtractDecider` that enforces only the configured access-level diff --git a/Sources/SwiftExtract/SwiftAnalyzer+Foundation.swift b/Sources/SwiftExtract/SwiftAnalyzer+Foundation.swift index c9f23cfc4..33d877695 100644 --- a/Sources/SwiftExtract/SwiftAnalyzer+Foundation.swift +++ b/Sources/SwiftExtract/SwiftAnalyzer+Foundation.swift @@ -56,6 +56,7 @@ extension SwiftAnalyzer { ] for group in groups { + let group = group.filter { extractDecider.shouldExtractFoundationType($0.candidates[0]) } let resolved: [(primary: SwiftNominalTypeDeclaration, source: String, candidates: [SwiftNominalTypeDeclaration])] = group.compactMap { type in let candidates = type.candidates.compactMap { self.symbolTable[$0] } diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift index d3cf9ffa8..4973139b9 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift @@ -143,6 +143,12 @@ private let foundationEssentialsSourceFile: SourceFileSyntax = """ public init?(string: String) public var absoluteString: String { get } } + + public struct Locale {} + + public struct TimeZone {} + + public struct CGFloat {} """ private var foundationSourceFile: SourceFileSyntax { diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift index bcc167f03..3db29da16 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift @@ -55,6 +55,11 @@ public enum SwiftKnownType: Equatable { case essentialsUUID case foundationURL case essentialsURL + case foundationLocale + case essentialsLocale + case foundationTimeZone + case essentialsTimeZone + case foundationCGFloat public init?(kind: SwiftKnownTypeDeclKind, genericArguments: [SwiftType]?) { switch kind { @@ -112,6 +117,11 @@ public enum SwiftKnownType: Equatable { case .essentialsUUID: self = .essentialsUUID case .foundationURL: self = .foundationURL case .essentialsURL: self = .essentialsURL + case .foundationLocale: self = .foundationLocale + case .essentialsLocale: self = .essentialsLocale + case .foundationTimeZone: self = .foundationTimeZone + case .essentialsTimeZone: self = .essentialsTimeZone + case .foundationCGFloat: self = .foundationCGFloat } } @@ -155,6 +165,11 @@ public enum SwiftKnownType: Equatable { case .essentialsUUID: .essentialsUUID case .foundationURL: .foundationURL case .essentialsURL: .essentialsURL + case .foundationLocale: .foundationLocale + case .essentialsLocale: .essentialsLocale + case .foundationTimeZone: .foundationTimeZone + case .essentialsTimeZone: .essentialsTimeZone + case .foundationCGFloat: .foundationCGFloat } } } @@ -201,6 +216,11 @@ public enum SwiftKnownTypeDeclKind: String, Hashable { case essentialsUUID = "FoundationEssentials.UUID" case foundationURL = "Foundation.URL" case essentialsURL = "FoundationEssentials.URL" + case foundationLocale = "Foundation.Locale" + case essentialsLocale = "FoundationEssentials.Locale" + case foundationTimeZone = "Foundation.TimeZone" + case essentialsTimeZone = "FoundationEssentials.TimeZone" + case foundationCGFloat = "Foundation.CGFloat" public var moduleAndName: (module: String, name: String) { let qualified = self.rawValue diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index c45b00b2e..6228e8da1 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -181,6 +181,15 @@ public struct Configuration: Codable { enableJavaCallbacks ?? false } + /// If true, Foundation values cross as standard Java types instead of Swift-backed wrapper classes generated + /// into each module: `URL` as `java.net.URI`, `Date` as `java.util.Date`, `Data` as `byte[]`, `Locale` as + /// `java.util.Locale` and `TimeZone` as `java.util.TimeZone`. Values are copied when they cross, and a `Date` keeps + /// millisecond precision. `UUID` is always a `java.util.UUID`. Only supported in `jni` mode. + public var foundationTypesAsJavaTypes: Bool? + public var effectiveFoundationTypesAsJavaTypes: Bool { + foundationTypesAsJavaTypes ?? false + } + /// If specified, JExtract will output to this file a list of paths to all generated Java source files. public var generatedJavaSourcesListFileOutput: String? diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index 24d50e508..8925b783e 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -703,6 +703,27 @@ Java returns an `Optional`. } } +#### Foundation values as Java types + +The `Data`, `Date` and `URL` wrapper classes are generated into the Java package of every module that uses +them, so a `URL` returned by one module's API is a different Java type from the `URL` another module's API +accepts. With `"foundationTypesAsJavaTypes": true` in `swift-java.config` (JNI mode), Foundation values cross +as standard Java types instead, and no wrapper classes are generated: + +| Swift | Java | +|---|---| +| `URL` | `java.net.URI` | +| `Date` | `java.util.Date` | +| `Data` | `byte[]` | +| `Locale` | `java.util.Locale` | +| `TimeZone` | `java.util.TimeZone` | + +They work wherever a `String` does: as parameters and results, optionals, arrays, and closure parameters. +Values are copied when they cross. A `Date` keeps millisecond precision, and a `Locale` crosses as its +language tag (`pt-BR` in Java for `pt_BR` in Swift). Java-implemented protocols don't support these types yet. + +`CGFloat` is a `double` in Java, with or without this option. + ### Optional parameters and return types Optional primitives use Java's `OptionalLong`, `OptionalInt`, etc. diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index bacce7ccf..624bc239e 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -246,6 +246,18 @@ only supported in `jni` mode. --- +#### foundationTypesAsJavaTypes + +- **Type:** `Bool?` +- **Default:** `false` + +If true, Foundation values cross as standard Java types instead of Swift-backed wrapper classes generated +into each module: `URL` as `java.net.URI`, `Date` as `java.util.Date`, `Data` as `byte[]`, `Locale` as +`java.util.Locale` and `TimeZone` as `java.util.TimeZone`. Values are copied when they cross, and a `Date` keeps +millisecond precision. `UUID` is always a `java.util.UUID`. Only supported in `jni` mode. + +--- + #### generatedJavaSourcesListFileOutput - **Type:** `String?` diff --git a/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift b/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift new file mode 100644 index 000000000..643bfb52c --- /dev/null +++ b/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift @@ -0,0 +1,298 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftJava +import SwiftJavaJNICore + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +// Foundation values that cross to Java as standard Java types when jextract's `foundationTypesAsJavaTypes` is +// enabled. Each value is copied: `URL` <-> `java.net.URI`, `Date` <-> `java.util.Date` (milliseconds), +// `Data` <-> `byte[]`, `Locale` <-> `java.util.Locale` and `TimeZone` <-> `java.util.TimeZone`. + +/// A `JavaValue` represented in Java by an object reference. +public protocol _JNIObjectJavaValue: JavaValue where JNIType == jobject? { + /// The JNI name of the Java class, such as `java/net/URI`, used for arrays of this value. + static var _jniClassName: String { get } +} + +extension _JNIObjectJavaValue { + public static var jvalueKeyPath: WritableKeyPath { \.l } + + public static func jniMethodCall(in environment: JNIEnvironment) -> JNIMethodCall { + environment.interface.CallObjectMethodA + } + + public static func jniFieldGet(in environment: JNIEnvironment) -> JNIFieldGet { + environment.interface.GetObjectField + } + + public static func jniFieldSet(in environment: JNIEnvironment) -> JNIFieldSet { + environment.interface.SetObjectField + } + + public static func jniStaticMethodCall(in environment: JNIEnvironment) -> JNIStaticMethodCall { + environment.interface.CallStaticObjectMethodA + } + + public static func jniStaticFieldGet(in environment: JNIEnvironment) -> JNIStaticFieldGet { + environment.interface.GetStaticObjectField + } + + public static func jniStaticFieldSet(in environment: JNIEnvironment) -> JNIStaticFieldSet { + environment.interface.SetStaticObjectField + } + + public static func jniNewArray(in environment: JNIEnvironment) -> JNINewArray { + { environment, size in + let elementClass = environment.interface.FindClass(environment, _jniClassName) + defer { environment.interface.DeleteLocalRef(environment, elementClass) } + return environment.interface.NewObjectArray(environment, size, elementClass, nil) + } + } + + public static func jniGetArrayRegion(in environment: JNIEnvironment) -> JNIGetArrayRegion { + { environment, array, start, length, outPointer in + let buffer = UnsafeMutableBufferPointer(start: outPointer, count: Int(length)) + for i in 0.. JNISetArrayRegion { + { environment, array, start, length, inPointer in + let buffer = UnsafeBufferPointer(start: inPointer, count: Int(length)) + for i in 0.. String { + defer { environment.interface.DeleteLocalRef(environment, string) } + return String(fromJNI: string, in: environment) +} + +/// Calls a method of `object` that returns a `java.lang.String`. +private func callStringMethod( + _ method: _JNIMethodIDCache.Method, + of cache: _JNIMethodIDCache, + on object: jobject, + in environment: JNIEnvironment +) -> String { + takeString(environment.interface.CallObjectMethodA(environment, object, cache[method]!, nil), in: environment) +} + +/// Calls a static factory of `cache`'s class that takes one `java.lang.String`. +private func callStaticFactory( + _ method: _JNIMethodIDCache.Method, + of cache: _JNIMethodIDCache, + with argument: String, + in environment: JNIEnvironment +) -> jobject? { + let string = argument.getJNIValue(in: environment) + defer { environment.interface.DeleteLocalRef(environment, string) } + return environment.interface.CallStaticObjectMethodA( + environment, + cache.javaClass, + cache[method]!, + [jvalue(l: string)] + ) +} + +// ==== ----------------------------------------------------------------------- +// MARK: URL <-> java.net.URI + +private let uriCreate = _JNIMethodIDCache.Method( + name: "create", + signature: "(Ljava/lang/String;)Ljava/net/URI;", + isStatic: true +) +private let uriToString = _JNIMethodIDCache.Method(name: "toString", signature: "()Ljava/lang/String;") +private let uriCache = _JNIMethodIDCache(className: "java/net/URI", methods: [uriCreate, uriToString]) + +extension URL: @retroactive JavaValue, _JNIObjectJavaValue { + public typealias JNIType = jobject? + + public static var javaType: JavaType { .class(package: "java.net", name: "URI") } + public static var _jniClassName: String { "java/net/URI" } + + public init(fromJNI value: jobject?, in environment: JNIEnvironment) { + let string = callStringMethod(uriToString, of: uriCache, on: value!, in: environment) + guard let url = URL(string: string) else { + fatalError("java.net.URI '\(string)' is not a valid Swift URL") + } + self = url + } + + public func getJNIValue(in environment: JNIEnvironment) -> jobject? { + callStaticFactory(uriCreate, of: uriCache, with: absoluteString, in: environment) + } +} + +// ==== ----------------------------------------------------------------------- +// MARK: Date <-> java.util.Date + +private let dateInit = _JNIMethodIDCache.Method(name: "", signature: "(J)V") +private let dateGetTime = _JNIMethodIDCache.Method(name: "getTime", signature: "()J") +private let dateCache = _JNIMethodIDCache(className: "java/util/Date", methods: [dateInit, dateGetTime]) + +extension Date: @retroactive JavaValue, _JNIObjectJavaValue { + public typealias JNIType = jobject? + + public static var javaType: JavaType { .class(package: "java.util", name: "Date") } + public static var _jniClassName: String { "java/util/Date" } + + public init(fromJNI value: jobject?, in environment: JNIEnvironment) { + let milliseconds = environment.interface.CallLongMethodA(environment, value!, dateCache[dateGetTime]!, nil) + self.init(timeIntervalSince1970: Double(milliseconds) / 1000) + } + + public func getJNIValue(in environment: JNIEnvironment) -> jobject? { + let milliseconds = Int64((timeIntervalSince1970 * 1000).rounded(.down)) + return environment.interface.NewObjectA( + environment, + dateCache.javaClass, + dateCache[dateInit]!, + [jvalue(j: milliseconds)] + ) + } +} + +// ==== ----------------------------------------------------------------------- +// MARK: Data <-> byte[] + +extension Data: @retroactive JavaValue, _JNIObjectJavaValue { + public typealias JNIType = jobject? + + public static var javaType: JavaType { .array(.byte) } + public static var _jniClassName: String { "[B" } + + public init(fromJNI value: jobject?, in environment: JNIEnvironment) { + let count = environment.interface.GetArrayLength(environment, value) + var data = Data(count: Int(count)) + data.withUnsafeMutableBytes { buffer in + environment.interface.GetByteArrayRegion( + environment, + value, + 0, + count, + buffer.baseAddress?.assumingMemoryBound(to: jbyte.self) + ) + } + self = data + } + + public func getJNIValue(in environment: JNIEnvironment) -> jobject? { + let array = environment.interface.NewByteArray(environment, jsize(count)) + withUnsafeBytes { buffer in + environment.interface.SetByteArrayRegion( + environment, + array, + 0, + jsize(buffer.count), + buffer.baseAddress?.assumingMemoryBound(to: jbyte.self) + ) + } + return array + } +} + +// ==== ----------------------------------------------------------------------- +// MARK: Locale <-> java.util.Locale + +private let localeForLanguageTag = _JNIMethodIDCache.Method( + name: "forLanguageTag", + signature: "(Ljava/lang/String;)Ljava/util/Locale;", + isStatic: true +) +private let localeToLanguageTag = _JNIMethodIDCache.Method(name: "toLanguageTag", signature: "()Ljava/lang/String;") +private let localeCache = _JNIMethodIDCache( + className: "java/util/Locale", + methods: [localeForLanguageTag, localeToLanguageTag] +) + +extension Locale: @retroactive JavaValue, _JNIObjectJavaValue { + public typealias JNIType = jobject? + + public static var javaType: JavaType { .class(package: "java.util", name: "Locale") } + public static var _jniClassName: String { "java/util/Locale" } + + // Java language tags separate subtags with `-`, Swift locale identifiers with `_`. + public init(fromJNI value: jobject?, in environment: JNIEnvironment) { + let tag = callStringMethod(localeToLanguageTag, of: localeCache, on: value!, in: environment) + self.init(identifier: String(tag.map { $0 == "-" ? "_" : $0 })) + } + + public func getJNIValue(in environment: JNIEnvironment) -> jobject? { + let tag = String(identifier.map { $0 == "_" ? "-" : $0 }) + return callStaticFactory(localeForLanguageTag, of: localeCache, with: tag, in: environment) + } +} + +// ==== ----------------------------------------------------------------------- +// MARK: TimeZone <-> java.util.TimeZone + +private let timeZoneGetTimeZone = _JNIMethodIDCache.Method( + name: "getTimeZone", + signature: "(Ljava/lang/String;)Ljava/util/TimeZone;", + isStatic: true +) +private let timeZoneGetID = _JNIMethodIDCache.Method(name: "getID", signature: "()Ljava/lang/String;") +private let timeZoneGetRawOffset = _JNIMethodIDCache.Method(name: "getRawOffset", signature: "()I") +private let timeZoneCache = _JNIMethodIDCache( + className: "java/util/TimeZone", + methods: [timeZoneGetTimeZone, timeZoneGetID, timeZoneGetRawOffset] +) + +extension TimeZone: @retroactive JavaValue, _JNIObjectJavaValue { + public typealias JNIType = jobject? + + public static var javaType: JavaType { .class(package: "java.util", name: "TimeZone") } + public static var _jniClassName: String { "java/util/TimeZone" } + + public init(fromJNI value: jobject?, in environment: JNIEnvironment) { + let identifier = callStringMethod(timeZoneGetID, of: timeZoneCache, on: value!, in: environment) + if let timeZone = TimeZone(identifier: identifier) { + self = timeZone + return + } + // A Java ID Swift doesn't know, such as a custom "GMT+05:30": keep its offset. + let milliseconds = environment.interface.CallIntMethodA( + environment, + value!, + timeZoneCache[timeZoneGetRawOffset]!, + nil + ) + self = TimeZone(secondsFromGMT: Int(milliseconds) / 1000)! + } + + public func getJNIValue(in environment: JNIEnvironment) -> jobject? { + callStaticFactory(timeZoneGetTimeZone, of: timeZoneCache, with: identifier, in: environment) + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIFoundationJavaTypesTests.swift b/Tests/JExtractSwiftTests/JNI/JNIFoundationJavaTypesTests.swift new file mode 100644 index 000000000..42ddb74f7 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIFoundationJavaTypesTests.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// With `foundationTypesAsJavaTypes`, Foundation values cross as standard Java types. +@Suite +struct JNIFoundationJavaTypesTests { + let source = + """ + import Foundation + + public struct Profile { + public var homepage: URL + public var avatar: URL? + public var joined: Date + public var photo: Data + public var locale: Locale + public var timeZone: TimeZone + public var scale: CGFloat + public var links: [URL] + + public func visit(_ body: (URL, Date) -> Void) {} + public func update(since: Date?, photo: Data?) {} + } + """ + + var config: Configuration { + var config = Configuration() + config.mode = .jni + config.foundationTypesAsJavaTypes = true + return config + } + + @Test + func javaBindings() throws { + try assertOutput( + input: source, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "private static native java.net.URI $getHomepage(long selfPointer);", + "public void setAvatar(Optional newValue) {", + "private static native void $setAvatar(byte newValue_discriminator, java.net.URI newValue_value, long selfPointer);", + "private static native java.util.Date $getJoined(long selfPointer);", + "private static native byte[] $getPhoto(long selfPointer);", + "private static native java.util.Locale $getLocale(long selfPointer);", + "private static native java.util.TimeZone $getTimeZone(long selfPointer);", + "private static native double $getScale(long selfPointer);", + "private static native java.net.URI[] $getLinks(long selfPointer);", + "void apply(java.net.URI _0, java.util.Date _1);", + "public void update(Optional since, Optional photo) {", + ], + notExpectedChunks: [ + "class URL", + "class Date", + "class Data", + ] + ) + } + + @Test + func swiftThunks() throws { + try assertOutput( + input: source, + config: config, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "selfPointer$.pointee.homepage = URL(fromJNI: newValue, in: environment)", + "selfPointer$.pointee.avatar = newValue_discriminator == 1 ? URL(fromJNI: newValue_value, in: environment) : nil", + "selfPointer$.pointee.photo = Data(fromJNI: newValue, in: environment)", + "selfPointer$.pointee.scale = CGFloat(newValue$indirect)", + "return Double(selfPointer$.pointee.scale).getJNILocalRefValue(in: environment)", + ] + ) + } + + @Test + func cgFloatIsDoubleWithoutTheOption() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "private static native double $getScale(long selfPointer);" + ] + ) + } +} diff --git a/Tests/SwiftJavaTests/FoundationJavaValueTests.swift b/Tests/SwiftJavaTests/FoundationJavaValueTests.swift new file mode 100644 index 000000000..3a0618d1a --- /dev/null +++ b/Tests/SwiftJavaTests/FoundationJavaValueTests.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import JavaNet +import SwiftJava +import SwiftJavaRuntimeSupport +import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// Foundation values that jextract's `foundationTypesAsJavaTypes` passes as standard Java types. +class FoundationJavaValueTests: XCTestCase { + private func roundTrip(_ value: T) throws -> T where T.JNIType == jobject? { + let environment = try JavaVirtualMachine.shared().environment() + let object = value.getJNIValue(in: environment) + defer { environment.interface.DeleteLocalRef(environment, object) } + return T(fromJNI: object, in: environment) + } + + func testURLIsJavaNetURI() throws { + let environment = try JavaVirtualMachine.shared().environment() + let url = URL(string: "https://swift.org/a%20b?q=1#top")! + let object = url.getJNIValue(in: environment) + XCTAssertEqual(URI(javaThis: object!, environment: environment).toString(), "https://swift.org/a%20b?q=1#top") + XCTAssertEqual(try roundTrip(url), url) + } + + func testDateKeepsMilliseconds() throws { + XCTAssertEqual(try roundTrip(Date(timeIntervalSince1970: 1_700_000_000.123)).timeIntervalSince1970, 1_700_000_000.123) + XCTAssertEqual(try roundTrip(Date(timeIntervalSince1970: -1.5)).timeIntervalSince1970, -1.5) + } + + func testDataIsByteArray() throws { + let environment = try JavaVirtualMachine.shared().environment() + let data = Data([0, 1, 127, 128, 255]) + let array = data.getJNIValue(in: environment) + defer { environment.interface.DeleteLocalRef(environment, array) } + XCTAssertEqual(environment.interface.GetArrayLength(environment, array), 5) + XCTAssertEqual(try roundTrip(data), data) + XCTAssertEqual(try roundTrip(Data()), Data()) + } + + func testLocaleUsesLanguageTags() throws { + XCTAssertEqual(try roundTrip(Locale(identifier: "pt_BR")).identifier, "pt_BR") + } + + func testTimeZone() throws { + XCTAssertEqual(try roundTrip(TimeZone(identifier: "America/New_York")!).identifier, "America/New_York") + } +}