From a3df4f9cd667c9ae503ac42d24fdbbf51ea738a9 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 14:34:18 +0100 Subject: [PATCH 1/2] JNI: Foundation values as standard Java types Ports swiftlang/swift-java#952: with `"foundationTypesAsJavaTypes": true`, `URL`, `Date`, `Data`, `Locale` and `TimeZone` cross as `java.net.URI`, `java.util.Date`, `byte[]`, `java.util.Locale` and `java.util.TimeZone` instead of wrapper classes generated into every module, through `JavaValue` conformances in SwiftJavaRuntimeSupport. `CGFloat` is a Java `double`. On top of #952, for this stack: - Escaping closures pass these values to Java as they are, since their `@JavaInterface` is printed by jextract and the values are `JavaValue`s, and closures returned to Java box them with a new `JavaObjectValueBridge`. Protocols implemented in Java still skip them: wrap-java maps the Java types on its own terms. - With `optionalsAsNullable`, an optional parameter whose Java type has a package, such as `Optional`, now becomes nullable too: its dots made the one-name spelling parse as a package and a class. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 1 + .../Convenience/JavaType+Extensions.swift | 8 +- .../JNI/JNIJavaTypeTranslator.swift | 30 +- ...Generator+InterfaceWrapperGeneration.swift | 27 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 38 ++- ...wift2JavaGenerator+NativeTranslation.swift | 27 +- .../JExtractSwiftLib/JavaExtractDecider.swift | 14 + .../JavaTypes/JavaType+JDK.swift | 16 + Sources/SwiftExtract/ExtractDecider.swift | 8 + .../SwiftAnalyzer+Foundation.swift | 1 + .../SwiftTypes/SwiftKnownModules.swift | 6 + .../SwiftTypes/SwiftKnownTypeDecls.swift | 20 ++ .../Configuration.swift | 9 + .../Documentation.docc/FeaturesJextract.md | 21 ++ .../Documentation.docc/SwiftJavaConfigFile.md | 68 +--- .../JavaValue+Foundation.swift | 320 ++++++++++++++++++ .../JNI/JNIFoundationJavaTypesTests.swift | 107 ++++++ .../FoundationJavaValueTests.swift | 65 ++++ 18 files changed, 697 insertions(+), 89 deletions(-) create mode 100644 Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift create mode 100644 Tests/JExtractSwiftTests/JNI/JNIFoundationJavaTypesTests.swift create mode 100644 Tests/SwiftJavaTests/FoundationJavaValueTests.swift diff --git a/Package.swift b/Package.swift index 6fcb0bcb8..4c645ba44 100644 --- a/Package.swift +++ b/Package.swift @@ -481,6 +481,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 785229b86..a86590527 100644 --- a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift @@ -42,21 +42,31 @@ 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, .asyncStream, .asyncThrowingStream, .error, - .foundationDate, .essentialsDate, - .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationUUID, .essentialsUUID: return nil } } @@ -68,6 +78,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, @@ -85,7 +96,9 @@ enum JNIJavaTypeTranslator { .asyncStream, .asyncThrowingStream, .error, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationURL, .essentialsURL, + .foundationLocale, .essentialsLocale, + .foundationTimeZone, .essentialsTimeZone: nil } } @@ -114,7 +127,10 @@ enum JNIJavaTypeTranslator { .asyncStream, .asyncThrowingStream, .error, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, - .foundationURL, .essentialsURL: + .foundationURL, .essentialsURL, + .foundationLocale, .essentialsLocale, + .foundationTimeZone, .essentialsTimeZone, + .foundationCGFloat: nil } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 9ca696d79..81c018ea0 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -34,6 +34,8 @@ extension JNISwift2JavaGenerator { } do { + // wrap-java maps `java.net.URI` and the other Foundation Java types on its own terms, so + // protocols implemented in Java don't pass Foundation values as they are; closures do. let translator = JavaInterfaceProtocolWrapperGenerator(asyncFuncMode: config.effectiveAsyncFuncMode) wrappers[type] = try translator.generate(for: type) } catch { @@ -105,6 +107,13 @@ extension JNISwift2JavaGenerator { struct JavaInterfaceProtocolWrapperGenerator { var asyncFuncMode: JExtractAsyncFuncMode = .default + /// Foundation values are `JavaValue`s that cross as Java types, as `String` does. Only for closures, whose + /// `@JavaInterface` jextract prints itself. + var foundationTypesAsJavaTypes: Bool = false + + private func crossesAsIs(_ type: SwiftType) -> Bool { + type.isDirectlyTranslatedToWrapJava || (foundationTypesAsJavaTypes && type.isFoundationJavaValue) + } func generate(for type: ExtractedNominalType) throws -> JavaInterfaceSwiftWrapper { if !type.initializers.isEmpty @@ -238,7 +247,7 @@ extension JNISwift2JavaGenerator { private func translateParameter(parameterName: String, type: SwiftType) throws -> UpcallConversionStep { - if type.isDirectlyTranslatedToWrapJava { + if crossesAsIs(type) { return .placeholder } if type.isSwiftError { @@ -322,7 +331,7 @@ extension JNISwift2JavaGenerator { methodName: String, allowNilForObjects: Bool = false ) throws -> UpcallConversionStep { - if type.isDirectlyTranslatedToWrapJava { + if crossesAsIs(type) { return .placeholder } @@ -507,6 +516,20 @@ enum UpcallConversionStep { } extension SwiftType { + /// A Foundation value that conforms to `JavaValue` in SwiftJavaRuntimeSupport, used with `foundationTypesAsJavaTypes` + var isFoundationJavaValue: Bool { + guard case .nominal(let nominal) = self, let kind = nominal.nominalTypeDecl.knownTypeKind else { + return false + } + switch kind { + case .foundationURL, .essentialsURL, .foundationDate, .essentialsDate, .foundationData, .essentialsData, + .foundationLocale, .essentialsLocale, .foundationTimeZone, .essentialsTimeZone: + return true + default: + return false + } + } + /// Indicates whether this type is translated by `wrap-java` /// into the same type as `jextract`. /// diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 1a832b1d5..2f2bc9ec5 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -664,7 +664,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: @@ -1105,14 +1110,17 @@ extension JNISwift2JavaGenerator { guard case .class(_, let name, let typeParameters) = type else { return nil } + // Spelled as one class name, such as `Optional` or `Optional`. The dots in + // the latter split it into a package and a name, so match the whole spelling. + let spelling = type.description + if typeParameters.isEmpty, spelling.hasPrefix("Optional<"), spelling.hasSuffix(">") { + return (JavaType(className: String(spelling.dropFirst("Optional<".count).dropLast())), "get") + } switch name { case "OptionalLong": return (.class(package: "java.lang", name: "Long"), "getAsLong") case "OptionalInt": return (.class(package: "java.lang", name: "Integer"), "getAsInt") case "OptionalDouble": return (.class(package: "java.lang", name: "Double"), "getAsDouble") case "Optional" where typeParameters.count == 1: return (typeParameters[0], "get") - // Primitive optionals spelled as one class name, such as `Optional` - case _ where name.hasPrefix("Optional<") && name.hasSuffix(">"): - return (JavaType(className: name.dropFirst("Optional<".count).dropLast()), "get") default: return nil } } @@ -1169,11 +1177,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 @@ -1309,7 +1319,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 @@ -1499,16 +1514,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 0beda8f2d..66f57da40 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -199,7 +199,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 @@ -438,7 +443,10 @@ extension JNISwift2JavaGenerator { method: methodName, qualifier: parameterName, ) - let generator = JavaInterfaceProtocolWrapperGenerator(asyncFuncMode: config.effectiveAsyncFuncMode) + let generator = JavaInterfaceProtocolWrapperGenerator( + asyncFuncMode: config.effectiveAsyncFuncMode, + foundationTypesAsJavaTypes: config.effectiveFoundationTypesAsJavaTypes + ) let syntheticFunction = try generator.generateSyntheticClosureFunction( functionType: functionType, javaInterfaceName: javaClosureInterfaceName, @@ -591,11 +599,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 @@ -909,7 +919,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 @@ -1370,6 +1385,8 @@ extension JNISwift2JavaGenerator { return "SetBridge<\(try bridgeTypeName(for: element))>" case .bool, .int, .uint, .int8, .uint8, .int16, .uint16, .int32, .uint32, .int64, .uint64, .float, .double, .string: return "JavaBoxableBridge<\(swiftType)>" + case _ where config.effectiveFoundationTypesAsJavaTypes && swiftType.isFoundationJavaValue: + return "JavaObjectValueBridge<\(swiftType)>" default: throw JavaTranslationError.unsupportedSwiftType(swiftType) } diff --git a/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index 4713bfdd1..026d62cb4 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -32,6 +32,7 @@ public func makeSwiftJavaAnalyzer( modes[moduleName] = dependency.configuration.effectiveExportMode } }, + foundationTypesAsJavaTypes: config.effectiveMode == .jni && config.effectiveFoundationTypesAsJavaTypes, logLevel: config.logLevel ?? .info ), diagnosticsSink: diagnosticsSink @@ -62,6 +63,8 @@ public struct JavaExtractDecider: ExtractDecider { public let swiftModuleName: String? /// The export mode of each dependency module, from its `swift-java.config` public let dependencyExportModes: [String: JExtractExportMode] + /// 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( @@ -69,12 +72,14 @@ public struct JavaExtractDecider: ExtractDecider { exportMode: JExtractExportMode = .default, swiftModuleName: String? = nil, dependencyExportModes: [String: JExtractExportMode] = [:], + foundationTypesAsJavaTypes: Bool = false, logLevel: LogLevel = .info ) { self.accessLevel = accessLevel self.exportMode = exportMode self.swiftModuleName = swiftModuleName self.dependencyExportModes = dependencyExportModes + self.foundationTypesAsJavaTypes = foundationTypesAsJavaTypes self.log = Logger(label: "JavaExtractDecider", logLevel: logLevel) } @@ -163,4 +168,13 @@ public struct JavaExtractDecider: ExtractDecider { case nil: exportMode == .all || extensionDecl.containsJavaExportedMember } } + + 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 3a543673e..8e69d227b 100644 --- a/Sources/SwiftExtract/ExtractDecider.swift +++ b/Sources/SwiftExtract/ExtractDecider.swift @@ -46,6 +46,10 @@ public protocol ExtractDecider { /// Whether `type`, from this module or a dependency, is exported. A declaration whose signature uses a /// type that isn't exported is skipped, since the generated code would refer to a type that doesn't exist. func isExported(_ type: SwiftNominalTypeDeclaration) -> 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 { @@ -53,6 +57,10 @@ extension ExtractDecider { true } + public func shouldExtractFoundationType(_ kind: SwiftKnownTypeDeclKind) -> Bool { + true + } + public func isExported(_ type: SwiftNominalTypeDeclaration) -> Bool { true } 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 150306878..d0ed74e1c 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift @@ -156,6 +156,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 8ec7e8534..a40a57f78 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift @@ -58,6 +58,11 @@ public enum SwiftKnownType: Equatable { case foundationURL case essentialsURL case error + case foundationLocale + case essentialsLocale + case foundationTimeZone + case essentialsTimeZone + case foundationCGFloat public init?(kind: SwiftKnownTypeDeclKind, genericArguments: [SwiftType]?) { switch kind { @@ -122,6 +127,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 } } @@ -168,6 +178,11 @@ public enum SwiftKnownType: Equatable { case .foundationURL: .foundationURL case .essentialsURL: .essentialsURL case .error: .error + case .foundationLocale: .foundationLocale + case .essentialsLocale: .essentialsLocale + case .foundationTimeZone: .foundationTimeZone + case .essentialsTimeZone: .essentialsTimeZone + case .foundationCGFloat: .foundationCGFloat } } } @@ -217,6 +232,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 df268bb67..c61d45ecc 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -224,6 +224,15 @@ public struct Configuration: Codable { /// of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`. public var nonNullAnnotation: String? + /// 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 dbeb57e0a..0bb187d04 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -718,6 +718,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 0cdfe265d..624bc239e 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -246,73 +246,15 @@ only supported in `jni` mode. --- -#### exportMode - -- **Type:** `JExtractExportMode?` -- **Default:** `all` - -Which declarations to export: `all` (default) exports every declaration that passes the access level -and filters, except those opted out with `@JavaExport(false)`. `annotated` exports only declarations marked -`@JavaExport`, including the members of a marked type or extension. - -**Values:** - -- `all` - Export every declaration that passes the access level and filters, except those marked `@JavaExport(false)` or inside a type or extension marked `@JavaExport(false)`. -- `annotated` - Export only declarations marked `@JavaExport`, or inside a type or extension marked `@JavaExport`. A type that isn't marked is still exported when one of its members is. - ---- - -#### failOnSkippedDeclarations - -- **Type:** `Bool?` -- **Default:** `false` - -If true, JExtract fails after generating when it skipped any declaration it could not -extract or translate, and lists each one. Skipped declarations are always reported as warnings. - ---- - -#### payloadlessEnumsAsJavaEnums - -- **Type:** `Bool?` -- **Default:** `false` - -If true, Swift enums whose cases have no associated values are generated as Java `enum`s, -with one constant per case named like the case, instead of classes wrapping a Swift value. Only supported in -`jni` mode. - ---- - -#### optionalsAsNullable +#### foundationTypesAsJavaTypes - **Type:** `Bool?` - **Default:** `false` -If true, optional parameters and results of functions and properties use the wrapped Java type, -with `null` for `nil`, instead of `Optional`, `OptionalLong`, `OptionalInt` or `OptionalDouble`. -Only supported in `jni` mode. - ---- - -#### nullableAnnotation - -- **Type:** `String?` - -Fully qualified name of a Java annotation marking parameters and results that can be `null`, such as -`androidx.annotation.Nullable` or `org.jetbrains.annotations.Nullable`, so Kotlin sees nullable types -instead of platform types. Applied to optionals with `optionalsAsNullable`. It must be applicable to -methods and parameters: type-use only annotations, such as JSpecify's, aren't supported. Only supported -in `jni` mode. - ---- - -#### nonNullAnnotation - -- **Type:** `String?` - -Fully qualified name of a Java annotation marking parameters and results that are never `null`, such as -`androidx.annotation.NonNull` or `org.jetbrains.annotations.NotNull`, so Kotlin sees non-null types instead -of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`. +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. --- diff --git a/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift b/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift new file mode 100644 index 000000000..415520a55 --- /dev/null +++ b/Sources/SwiftJavaRuntimeSupport/JavaValue+Foundation.swift @@ -0,0 +1,320 @@ +//===----------------------------------------------------------------------===// +// +// 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..: JobjectBridge { + public typealias SwiftType = T + + public static func toJavaObject(_ value: T, in environment: JNIEnvironment) -> jobject? { + value.getJNIValue(in: environment) + } + + public static func fromJavaObject(_ obj: jobject?, in environment: JNIEnvironment) -> T { + T(fromJNI: obj, in: environment) + } + + public static func withJNIClass( + in environment: JNIEnvironment, + _ body: (jclass) throws -> Result + ) throws -> Result { + let javaClass = environment.interface.FindClass(environment, T._jniClassName)! + defer { environment.interface.DeleteLocalRef(environment, javaClass) } + return try body(javaClass) + } +} + +/// Reads a `java.lang.String` result and releases its local reference. +private func takeString(_ string: jobject?, in environment: JNIEnvironment) -> 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") + } +} From 77f6701a54e08c118afcd4e25e55e8598f1fc67d Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 14:34:18 +0100 Subject: [PATCH 2/2] Samples: Foundation values as Kotlin's Java types Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/Bookmarks.swift | 53 ++++++++++++++ .../KotlinSampleLibrary/swift-java.config | 3 +- .../example/kotlin/FoundationValuesTest.kt | 71 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Bookmarks.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/FoundationValuesTest.kt diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Bookmarks.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Bookmarks.swift new file mode 100644 index 000000000..270f278c3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Bookmarks.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation + +/// Foundation values cross as standard Java types (`foundationTypesAsJavaTypes`). +public final class Bookmark { + public var url: URL + public var icon: URL? + public var created: Date + public var thumbnail: Data + public var locale: Locale + public var timeZone: TimeZone + public var zoom: CGFloat + public var onOpen: ((URL) -> Void)? + + public init(url: URL, icon: URL?, created: Date, thumbnail: Data, locale: Locale, timeZone: TimeZone, zoom: CGFloat) { + self.url = url + self.icon = icon + self.created = created + self.thumbnail = thumbnail + self.locale = locale + self.timeZone = timeZone + self.zoom = zoom + } + + public func open() { + onOpen?(url) + } + + public func observeVisits(_ onVisit: @escaping (URL, Date) -> Void) { + onVisit(url, created) + } + + public func related() -> [URL] { + [url, url.appendingPathComponent("more")] + } + + public func refreshed() async -> Date { + created.addingTimeInterval(60) + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index adb4ff47c..f8d3303ec 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -6,5 +6,6 @@ "payloadlessEnumsAsJavaEnums": true, "optionalsAsNullable": true, "nullableAnnotation": "org.jetbrains.annotations.Nullable", - "nonNullAnnotation": "org.jetbrains.annotations.NotNull" + "nonNullAnnotation": "org.jetbrains.annotations.NotNull", + "foundationTypesAsJavaTypes": true } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/FoundationValuesTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/FoundationValuesTest.kt new file mode 100644 index 000000000..d88a13a44 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/FoundationValuesTest.kt @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +package com.example.kotlin + +import java.net.URI +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +// URL, Date, Data, Locale and TimeZone are java.net.URI, java.util.Date, ByteArray, java.util.Locale and +// java.util.TimeZone in Kotlin, and CGFloat is a Double. +class FoundationValuesTest { + private fun bookmark() = Bookmark.init( + URI.create("https://swift.org/docs"), + null, + Date(1_700_000_000_123), + byteArrayOf(1, 2, -1), + Locale.forLanguageTag("pt-BR"), + TimeZone.getTimeZone("Europe/Rome"), + 1.5, + ) + + @Test + fun valuesRoundTrip() { + val bookmark = bookmark() + assertEquals(URI.create("https://swift.org/docs"), bookmark.url) + assertNull(bookmark.icon) + bookmark.icon = URI.create("https://swift.org/favicon.ico") + assertEquals("https://swift.org/favicon.ico", bookmark.icon.toString()) + assertEquals(1_700_000_000_123, bookmark.created.time) + assertContentEquals(byteArrayOf(1, 2, -1), bookmark.thumbnail) + assertEquals("pt-BR", bookmark.locale.toLanguageTag()) + assertEquals("Europe/Rome", bookmark.timeZone.id) + assertEquals(1.5, bookmark.zoom) + assertEquals(listOf("https://swift.org/docs", "https://swift.org/docs/more"), bookmark.related().map { it.toString() }) + } + + @Test + fun closures() { + val bookmark = bookmark() + val opened = mutableListOf() + bookmark.onOpen = { opened += it } + bookmark.open() + assertEquals(listOf(URI.create("https://swift.org/docs")), opened) + + var visit: Pair? = null + bookmark.observeVisits { url, date -> visit = url to date } + assertEquals(URI.create("https://swift.org/docs") to Date(1_700_000_000_123), visit) + } + + @Test + fun asyncResult() { + assertEquals(1_700_000_060_123, bookmark().refreshed().get().time) + } +}