diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Identity.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Identity.swift new file mode 100644 index 000000000..e480e10a3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Identity.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A class with a shared instance: every Kotlin wrapper of it is equal. +public final class Session: Sendable { + public static let shared: Session = Session(name: "shared") + + public let name: String + + public init(name: String) { + self.name = name + } + + public static func echo(_ session: Session) -> Session { session } +} + +public enum Echo { + /// Returns the same instance through its superclass type. + public static func animal(_ animal: Animal) -> Animal { animal } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/IdentityTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/IdentityTest.kt new file mode 100644 index 000000000..9380745d8 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/IdentityTest.kt @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotSame + +// Kotlin compares objects with `==` (equals) and collections hash them: two wrappers of one Swift +// instance must be equal, as Compose relies on to skip recomposition. +class IdentityTest { + @Test + fun wrappersOfTheSameInstanceAreEqual() { + val first = Session.getShared() + val second = Session.getShared() + assertNotSame(first, second) + assertEquals(first, second) + assertEquals(1, setOf(first, second, Session.echo(first)).size) + assertNotEquals(first, Session.init("other")) + } + + @Test + fun sameInstanceThroughItsSuperclassType() { + val dog = Dog.init("Rex") + val back: Animal = Echo.animal(dog) + assertNotSame(dog, back) + assertEquals(dog, back) + assertEquals(dog.hashCode(), back.hashCode()) + assertNotEquals(dog, Dog.init("Rex")) + } + + @Test + fun enumCaseWithoutAnArena() { + val case = Store.init().route(Item.init("a", 1)).case + assertIs(case) + assertEquals("a", case.arg0.id) + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift index ecdf7e9c3..14ec2353f 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift @@ -45,3 +45,8 @@ public func totalArea(_ shapes: [Shape]) -> Double { public func makeSquareShape(side: Double) -> Shape { Square(side: side) } + +/// Returns the same instance: Java gets a new wrapper, which must equal the original. +public func sameShape(_ shape: Shape) -> Shape { + shape +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java index 97300d2d9..c92c2fdb3 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java @@ -39,4 +39,16 @@ void returnedInstanceHasItsDynamicClass() { assertEquals(16, shape.area()); } } + + @Test + void wrappersOfTheSameInstanceAreEqual() { + try (var arena = SwiftArena.ofConfined()) { + Square square = Square.init(2, arena); + Shape same = MySwiftLibrary.sameShape(square, arena); + assertNotSame(square, same); + assertEquals(square, same); + assertEquals(square.hashCode(), same.hashCode()); + assertNotEquals(square, Square.init(2, arena)); + } + } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 673ad57f0..71dbed448 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -764,6 +764,14 @@ extension JNISwift2JavaGenerator { } } } + + // With a global automatic arena, also offer `getCase()` without an arena, like other members. + if requiresSwiftArena && config.effectiveMemoryManagementMode.requiresGlobalArena { + printer.println() + printer.printBraceBlock("public Case\(caseGenericClause) getCase()") { printer in + printer.print("return getCase(SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA);") + } + } } /// Fully qualifies package-less class types whose leading name is shadowed by one of `names`. diff --git a/Sources/SwiftJava/SwiftObjects.swift b/Sources/SwiftJava/SwiftObjects.swift index d42e7120b..8520f8457 100644 --- a/Sources/SwiftJava/SwiftObjects.swift +++ b/Sources/SwiftJava/SwiftObjects.swift @@ -101,13 +101,27 @@ extension SwiftObjects { @JavaMethod public static func equals(environment: UnsafeMutablePointer!, lhsPointer: Int64, lhsTypePointer: Int64, rhsPointer: Int64, rhsTypePointer: Int64) -> Bool { - // Fallback for non-equatable types (such as classes) + // Fallback for non-equatable value types: the same memory let isPointerIdentityEqual = lhsTypePointer == rhsTypePointer && lhsPointer == rhsPointer guard let lhsType$ = UnsafeRawPointer(bitPattern: Int(lhsTypePointer)) else { fatalError("lhsType metadata address was null") } let lhsMetatype = unsafeBitCast(lhsType$, to: Any.Type.self) + + // Each Java wrapper of a class instance holds its own pointer to the reference, so compare the + // objects themselves: wrappers of the same instance are equal, whatever their static types. + if let lhsObject = Self.classInstance(at: lhsPointer, ofType: lhsMetatype), + let rhsType$ = UnsafeRawPointer(bitPattern: Int(rhsTypePointer)), + let rhsObject = Self.classInstance(at: rhsPointer, ofType: unsafeBitCast(rhsType$, to: Any.Type.self)) + { + if lhsObject === rhsObject { + return true + } + guard lhsMetatype is any Equatable.Type else { + return false + } + } guard let lhsMetatype = lhsMetatype as? (any Equatable.Type) else { return isPointerIdentityEqual } @@ -145,7 +159,15 @@ extension SwiftObjects { fatalError("selfType metadata address was null") } let typeMetadata = unsafeBitCast(selfType$, to: Any.Type.self) - guard let typeMetadata = typeMetadata as? (any Hashable.Type) else { + guard let hashableMetadata = typeMetadata as? (any Hashable.Type) else { + if let object = Self.classInstance(at: selfPointer, ofType: typeMetadata) { + // Consistent with `equals`: an `Equatable` class may equal other instances, so it can only + // hash by type; otherwise equality is identity. + if typeMetadata is any Equatable.Type { + return Int32(truncatingIfNeeded: ObjectIdentifier(typeMetadata).hashValue) + } + return Int32(truncatingIfNeeded: ObjectIdentifier(object).hashValue) + } // For value types, different instances may return different hash codes even if the values are same. return Int32(truncatingIfNeeded: selfPointer.hashValue) } @@ -156,7 +178,16 @@ extension SwiftObjects { } return Int32(truncatingIfNeeded: self$.pointee.hashValue) } - return perform(as: typeMetadata) + return perform(as: hashableMetadata) + } + + /// The class instance stored at `pointer`, or nil if `type` isn't a class type. + private static func classInstance(at pointer: Int64, ofType type: Any.Type) -> AnyObject? { + guard type is AnyClass, let raw = UnsafeRawPointer(bitPattern: Int(pointer)) else { + return nil + } + // A class reference has the layout of `AnyObject`. + return raw.load(as: AnyObject.self) } @JavaMethod diff --git a/Tests/JExtractSwiftTests/JNI/JNIEnumGetCaseArenaTests.swift b/Tests/JExtractSwiftTests/JNI/JNIEnumGetCaseArenaTests.swift new file mode 100644 index 000000000..1e619a555 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIEnumGetCaseArenaTests.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// `getCase()` on an enum with payloads gets an arena-free overload when a global automatic arena exists. +@Suite +struct JNIEnumGetCaseArenaTests { + let source = """ + public struct Item {} + public enum Route { + case home + case detail(Item) + } + """ + + @Test + func allowGlobalAutomatic_hasArenaFreeGetCase() throws { + var config = Configuration() + config.memoryManagementMode = .allowGlobalAutomatic + try assertOutput( + input: source, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public Case getCase(SwiftArena swiftArena) {", + """ + public Case getCase() { + return getCase(SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA); + } + """, + ] + ) + } + + @Test + func explicitMode_onlyHasGetCaseWithArena() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["public Case getCase(SwiftArena swiftArena) {"], + notExpectedChunks: ["public Case getCase() {"] + ) + } +}