From 3dc89a9a132438b5ea6a5a7099ef5552f0e96f5e Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 15:13:35 +0100 Subject: [PATCH 1/2] JNI: Swift class hierarchies as Java class hierarchies Every Swift class became an unrelated final Java class, so a subclass couldn't be used as its superclass, didn't expose inherited members, and an instance returned as its superclass couldn't be cast to its own class. - A class whose superclass is extracted in the same module extends the superclass's Java class. `open` classes and classes with extracted subclasses are no longer final and get a protected designated constructor; subclasses call `super(...)`, so the pointer and cleanup live in the root class. - A class with extracted subclasses gets a `$dynamicTypeMetadataAddress` thunk, and its `wrapMemoryAddressUnsafe` wraps an instance in the Java class of its dynamic type, deepest subclass first. - Inherited members need no new thunks: a class instance is a pointer to one reference, which the superclass's thunks read through the same layout. Co-Authored-By: Claude Opus 5.5 --- .../MySwiftLibrary/ClassHierarchy.swift | 47 ++++++ .../com/example/swift/ClassHierarchyTest.java | 42 +++++ ...t2JavaGenerator+JavaBindingsPrinting.swift | 97 +++++++++--- ...ift2JavaGenerator+SwiftThunkPrinting.swift | 21 +++ .../JNI/JNISwift2JavaGenerator.swift | 43 ++++++ .../JNI/JNIClassInheritanceTests.swift | 146 ++++++++++++++++++ 6 files changed, 371 insertions(+), 25 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java create mode 100644 Tests/JExtractSwiftTests/JNI/JNIClassInheritanceTests.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift new file mode 100644 index 000000000..ecdf7e9c3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClassHierarchy.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + + +/// Swift subclasses become Java subclasses. +open class Shape { + public let name: String + + public init(name: String) { + self.name = name + } + + open func area() -> Double { 0 } + + public func describe() -> String { "\(name): \(area())" } +} + +public final class Square: Shape { + public let side: Double + + public init(side: Double) { + self.side = side + super.init(name: "square") + } + + public override func area() -> Double { side * side } +} + +public func totalArea(_ shapes: [Shape]) -> Double { + shapes.reduce(0) { $0 + $1.area() } +} + +/// Returned as `Shape`; Java receives a `Square`. +public func makeSquareShape(side: Double) -> Shape { + Square(side: side) +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java new file mode 100644 index 000000000..97300d2d9 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClassHierarchyTest.java @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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.swift; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftArena; + +import static org.junit.jupiter.api.Assertions.*; + +public class ClassHierarchyTest { + @Test + void subclassIsASuperclass() { + try (var arena = SwiftArena.ofConfined()) { + Square square = Square.init(3, arena); + Shape shape = square; + assertEquals("square: 9.0", shape.describe()); + assertEquals(3, square.getSide()); + assertEquals(13, MySwiftLibrary.totalArea(new Shape[] { square, Square.init(2, arena) })); + } + } + + @Test + void returnedInstanceHasItsDynamicClass() { + try (var arena = SwiftArena.ofConfined()) { + Shape shape = MySwiftLibrary.makeSquareShape(4, arena); + assertInstanceOf(Square.class, shape); + assertEquals(16, shape.area()); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 3e598eda7..673ad57f0 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -364,7 +364,14 @@ extension JNISwift2JavaGenerator { swiftPointerParams.append("selfTypePointer") } let javaName = decl.effectiveJavaSimpleName - printDesignatedConstructor(&printer, javaName: javaName, pointerParams: swiftPointerParams) + let superclass = self.extractedSuperclass(of: decl) + printDesignatedConstructor( + &printer, + javaName: javaName, + pointerParams: swiftPointerParams, + isExtendable: self.isExtendableClass(decl), + hasSuperclass: superclass != nil + ) printer.println() let genericClause = decl.javaGenericClause printWrapMemoryAddressUnsafeFactory( @@ -372,27 +379,31 @@ extension JNISwift2JavaGenerator { javaName: javaName, genericClause: genericClause, pointerParams: swiftPointerParams, - docSummary: "Assume that the passed {@code long} represents a memory address of a {@link \(javaName)}." + docSummary: "Assume that the passed {@code long} represents a memory address of a {@link \(javaName)}.", + subclasses: self.extractedSubclasses(of: decl) ) - printer.print( - """ - /** Pointer to the "self". */ - private final long selfPointer; + // A subclass inherits the pointer and cleanup from the root of its hierarchy. + if superclass == nil { + printer.print( + """ + /** Pointer to the "self". */ + private final long selfPointer; - /** Tracks whether this instance has been destroyed; doubles as the destroyed-state holder. */ - private final SwiftInstanceCleanup $cleanup; + /** Tracks whether this instance has been destroyed; doubles as the destroyed-state holder. */ + private final SwiftInstanceCleanup $cleanup; - public long $memoryAddress() { - return this.selfPointer; - } + public long $memoryAddress() { + return this.selfPointer; + } - @Override - public SwiftInstanceCleanup $cleanup() { - return $cleanup; - } - """ - ) + @Override + public SwiftInstanceCleanup $cleanup() { + return $cleanup; + } + """ + ) + } if isEffectivelyGeneric { printer.print("/** Pointer to the metatype of Self */") @@ -444,6 +455,8 @@ extension JNISwift2JavaGenerator { _ printer: inout JavaPrinter, javaName: String, pointerParams: [String], + isExtendable: Bool = false, + hasSuperclass: Bool = false, ) { printer.print( """ @@ -456,7 +469,17 @@ extension JNISwift2JavaGenerator { """ ) let pointerArg = pointerParams.map { "long \($0)" }.joined(separator: ", ") - printer.printBraceBlock("private \(javaName)(\(pointerArg), SwiftArena swiftArena)") { printer in + // Subclasses of an extendable class call this constructor. + let visibility = isExtendable ? "protected" : "private" + if hasSuperclass { + // The root class of the hierarchy stores the pointer and registers the cleanup, whose destroy + // function uses the (overridden) `$typeMetadataAddress()` of this class. + printer.printBraceBlock("\(visibility) \(javaName)(\(pointerArg), SwiftArena swiftArena)") { printer in + printer.print("super(\(pointerParams.joined(separator: ", ")), swiftArena);") + } + return + } + printer.printBraceBlock("\(visibility) \(javaName)(\(pointerArg), SwiftArena swiftArena)") { printer in for param in pointerParams { printer.print( """ @@ -483,9 +506,18 @@ extension JNISwift2JavaGenerator { genericClause: String, pointerParams: [String], docSummary: String, + subclasses: [ExtractedNominalType] = [], ) { let pointerArg = pointerParams.map { "long \($0)" }.joined(separator: ", ") let pointerArgNames = pointerParams.joined(separator: ", ") + // A class with extracted subclasses wraps an instance in the Java class of its dynamic type. + let dynamicDispatch = + subclasses.isEmpty + ? "" + : (["long dynamicType$ = \(javaName).$dynamicTypeMetadataAddress(selfPointer);"] + + subclasses.map { subclass in + "if (dynamicType$ == \(subclass.effectiveJavaName).$typeMetadataAddressDowncall()) { return \(subclass.effectiveJavaName).wrapMemoryAddressUnsafe(\(pointerArgNames), swiftArena); }" + }).joined(separator: "\n ") + "\n " printer.print( """ /** @@ -498,14 +530,17 @@ extension JNISwift2JavaGenerator { * */ public static\(genericClause) \(javaName)\(genericClause) wrapMemoryAddressUnsafe(\(pointerArg), SwiftArena swiftArena) { - return new \(javaName)\(genericClause)(\(pointerArgNames), swiftArena); + \(dynamicDispatch)return new \(javaName)\(genericClause)(\(pointerArgNames), swiftArena); } public static\(genericClause) \(javaName)\(genericClause) wrapMemoryAddressUnsafe(\(pointerArg)) { - return new \(javaName)\(genericClause)(\(pointerArgNames), SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA); + \(subclasses.isEmpty ? "return new \(javaName)\(genericClause)(\(pointerArgNames), " : "return wrapMemoryAddressUnsafe(\(pointerArgNames), ")SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA); } """ ) + if !subclasses.isEmpty { + printer.print("private static native long $dynamicTypeMetadataAddress(long selfPointer);") + } } /// Prints common Swift object methods such as `equals`, `hashCode` etc. @@ -599,14 +634,24 @@ extension JNISwift2JavaGenerator { if decl.parent != nil { modifiers.append("static") } - modifiers.append("final") + if !self.isExtendableClass(decl) { + modifiers.append("final") + } let isErrorType = self.isSwiftErrorType(decl) - // Error types get `JNISwiftInstance` from their `SwiftError` base class. - var implements = isErrorType ? [] : ["JNISwiftInstance"] + let superclass = self.extractedSuperclass(of: decl) + // Error types get `JNISwiftInstance` from their `SwiftError` base class, subclasses from their superclass. + var implements = isErrorType || superclass != nil ? [] : ["JNISwiftInstance"] // Only protocols that were actually extracted have a generated Java interface to implement. implements += self.inheritedProtocols(of: decl).map(\.effectiveJavaSimpleName) // Fully qualified so a user type named `SwiftError` cannot shadow it. - let extendsClause = isErrorType ? " extends org.swift.swiftkit.core.SwiftError" : "" + let extendsClause = + if isErrorType { + " extends org.swift.swiftkit.core.SwiftError" + } else if let superclass { + " extends \(superclass.effectiveJavaName)" + } else { + "" + } let implementsClause = implements.isEmpty ? "" : " implements \(implements.joined(separator: .comma))" if isErrorType { printer.print("@SuppressWarnings(\"serial\")") @@ -1090,7 +1135,9 @@ extension JNISwift2JavaGenerator { printer.print("return this.selfTypePointer;") } } else { - printer.print("private static native long $typeMetadataAddressDowncall();") + // Package-private for a subclass, so its base class can match it when wrapping an instance. + let visibility = self.extractedSuperclass(of: type) == nil ? "private " : "" + printer.print("\(visibility)static native long $typeMetadataAddressDowncall();") printer.print("@Override") printer.printBraceBlock("public long $typeMetadataAddress()") { printer in // INFO: We are omitting `CallTraces.traceDowncall` here. diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index 898428219..66b73a916 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -1185,6 +1185,27 @@ extension JNISwift2JavaGenerator { """ ) } + + // A class with extracted subclasses: Java asks for an instance's dynamic type to pick the + // most specific Java class when wrapping it. + if !self.extractedSubclasses(of: type).isEmpty { + printer.println() + printCDecl( + &printer, + javaMethodName: "$dynamicTypeMetadataAddress", + parentName: type.effectiveJavaTypeName, + parameters: [JavaParameter(name: "selfPointer", type: .long)], + resultType: .long, + ) { printer in + printer.print( + """ + let self$ = UnsafeMutablePointer<\(type.effectiveSwiftTypeName)>(bitPattern: Int(Int64(fromJNI: selfPointer, in: environment)))! + let metadataPointer = unsafeBitCast(type(of: self$.pointee), to: UnsafeRawPointer.self) + return Int64(Int(bitPattern: metadataPointer)).getJNIValue(in: environment) + """ + ) + } + } } /// Prints thunks for specific known types like Foundation.Date, Foundation.Data diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 7d61a68a6..3327a608d 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -184,6 +184,49 @@ extension JNISwift2JavaGenerator { } } + /// Whether `type` is a concrete (non-generic) Swift class whose Java class can take part in a + /// Java class hierarchy. + func isHierarchyClass(_ type: ExtractedNominalType) -> Bool { + type.swiftNominal.kind == .class && !type.swiftNominal.isGeneric && !type.isSpecialization + && !self.isSwiftErrorType(type) + } + + /// The extracted superclass of `type` in this module, which its Java class extends. + func extractedSuperclass(of type: ExtractedNominalType) -> ExtractedNominalType? { + guard isHierarchyClass(type) else { return nil } + return type.inheritedTypes + .compactMap(\.asNominalTypeDeclaration) + .filter { $0.kind == .class } + .compactMap { self.analysis.extractedTypes[$0.qualifiedName] } + .first(where: isHierarchyClass) + } + + /// Extracted classes in this module that inherit from `type`, directly or not, deepest first, so + /// the most specific Java class is matched first when wrapping an instance of its dynamic type. + func extractedSubclasses(of type: ExtractedNominalType) -> [ExtractedNominalType] { + func depth(_ type: ExtractedNominalType) -> Int { + extractedSuperclass(of: type).map { depth($0) + 1 } ?? 0 + } + func inherits(_ candidate: ExtractedNominalType) -> Bool { + var current = extractedSuperclass(of: candidate) + while let superclass = current { + if superclass.swiftNominal.qualifiedName == type.swiftNominal.qualifiedName { return true } + current = extractedSuperclass(of: superclass) + } + return false + } + return analysis.extractedTypes.values + .filter(inherits) + .sorted { (depth($0), $0.swiftNominal.qualifiedName) > (depth($1), $1.swiftNominal.qualifiedName) } + } + + /// Whether `type`'s Java class can be extended: it's `open`, or has extracted subclasses. + func isExtendableClass(_ type: ExtractedNominalType) -> Bool { + guard isHierarchyClass(type) else { return false } + let isOpen = type.swiftNominal.syntax.modifiers.contains { $0.name.tokenKind == .keyword(.open) } + return isOpen || !extractedSubclasses(of: type).isEmpty + } + /// Whether `type` conforms to `Error` and is surfaced to Java as a `SwiftError` subclass. /// /// Types that never get a Java class or bridge (protocols, unspecialized generics, diff --git a/Tests/JExtractSwiftTests/JNI/JNIClassInheritanceTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClassInheritanceTests.swift new file mode 100644 index 000000000..f1cc4b351 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIClassInheritanceTests.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +/// Swift subclasses become Java subclasses, and instances are wrapped in the Java class of their +/// dynamic type. +@Suite +struct JNIClassInheritanceTests { + let source = """ + open class Animal { + public init() {} + open func sound() -> String { "" } + } + open class Dog: Animal { + public override init() {} + public func fetch() {} + } + public final class Puppy: Dog {} + public final class Plain {} + open class Lonely {} + public func makeAnimal() -> Animal { Puppy() } + """ + + @Test + func subclassExtendsItsSuperclass() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public class Animal implements JNISwiftInstance {", + "public class Dog extends Animal {", + "public final class Puppy extends Dog {", + ] + ) + } + + @Test + func constructors() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "protected Animal(long selfPointer, SwiftArena swiftArena) {", + """ + protected Dog(long selfPointer, SwiftArena swiftArena) { + super(selfPointer, swiftArena); + } + """, + """ + private Puppy(long selfPointer, SwiftArena swiftArena) { + super(selfPointer, swiftArena); + } + """, + ] + ) + } + + @Test + func wrappingDispatchesOnTheDynamicTypeDeepestFirst() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static Animal wrapMemoryAddressUnsafe(long selfPointer, SwiftArena swiftArena) { + long dynamicType$ = Animal.$dynamicTypeMetadataAddress(selfPointer); + if (dynamicType$ == Puppy.$typeMetadataAddressDowncall()) { return Puppy.wrapMemoryAddressUnsafe(selfPointer, swiftArena); } + if (dynamicType$ == Dog.$typeMetadataAddressDowncall()) { return Dog.wrapMemoryAddressUnsafe(selfPointer, swiftArena); } + return new Animal(selfPointer, swiftArena); + } + """, + "private static native long $dynamicTypeMetadataAddress(long selfPointer);", + // A subclass's metadata accessor is visible to its base classes. + "static native long $typeMetadataAddressDowncall();", + ] + ) + } + + @Test + func dynamicTypeThunk() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + let self$ = UnsafeMutablePointer(bitPattern: Int(Int64(fromJNI: selfPointer, in: environment)))! + let metadataPointer = unsafeBitCast(type(of: self$.pointee), to: UnsafeRawPointer.self) + """ + ] + ) + } + + @Test + func openClassWithoutSubclassesIsExtendable() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public class Lonely implements JNISwiftInstance {", + "protected Lonely(long selfPointer, SwiftArena swiftArena) {", + """ + public static Lonely wrapMemoryAddressUnsafe(long selfPointer, SwiftArena swiftArena) { + return new Lonely(selfPointer, swiftArena); + } + """, + ] + ) + } + + @Test + func plainClassIsUnchanged() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class Plain implements JNISwiftInstance {", + "private Plain(long selfPointer, SwiftArena swiftArena) {", + ] + ) + } +} From 002d8afe162b42aa85c91fe6175dfc5416a69fb6 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 15:40:31 +0100 Subject: [PATCH 2/2] Samples: Kotlin subclasses of Swift classes, and a view model as Compose uses it Animal/Dog for subclass-as-superclass and dynamic-class returns; and the full view-model shape: a main-actor observable open base class, a subclass with callbacks taking structs, an input enum with payloads, and async loading. Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/ClassHierarchy.swift | 43 ++++++++++ .../KotlinSampleLibrary/ViewModels.swift | 83 +++++++++++++++++++ .../com/example/kotlin/ClassHierarchyTest.kt | 42 ++++++++++ .../com/example/kotlin/ViewModelTest.kt | 49 +++++++++++ 4 files changed, 217 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ClassHierarchy.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ViewModels.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClassHierarchyTest.kt create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ViewModelTest.kt diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ClassHierarchy.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ClassHierarchy.swift new file mode 100644 index 000000000..1b683da77 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ClassHierarchy.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Swift subclasses become Kotlin-visible subclasses. +open class Animal { + public let name: String + + public init(name: String) { + self.name = name + } + + open func sound() -> String { "..." } + + public func describe() -> String { "\(name) says \(sound())" } +} + +public final class Dog: Animal { + public override init(name: String) { + super.init(name: name) + } + + public override func sound() -> String { "woof" } + + public func fetch() -> String { "stick" } +} + +public enum Zoo { + public static func describe(_ animal: Animal) -> String { animal.describe() } + + /// Returned as `Animal`; Kotlin receives a `Dog`. + public static func makeDog(name: String) -> Animal { Dog(name: name) } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ViewModels.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ViewModels.swift new file mode 100644 index 000000000..cade7a376 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ViewModels.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import Observation + +/// The shape of a Skip Fuse view model: a main-actor, observable open base class; a subclass with +/// callbacks taking structs, an input enum with payloads, and async loading. +@MainActor +@Observable +open class BaseViewModel { + public var isLoading = false + + public init() {} + + open func setup() {} +} + +@MainActor +public final class MarketViewModel: BaseViewModel { + public struct Callbacks: Sendable { + let onOpen: @MainActor @Sendable (Market) -> Void + let onClose: @MainActor @Sendable () -> Void + + public init( + onOpen: @escaping @MainActor @Sendable (Market) -> Void, + onClose: @escaping @MainActor @Sendable () -> Void + ) { + self.onOpen = onOpen + self.onClose = onClose + } + } + + public enum Input: Sendable { + case tapped(Market) + case closeTapped + } + + public private(set) var markets: [Market] = [] + private let callbacks: Callbacks + + public init(callbacks: Callbacks) { + self.callbacks = callbacks + super.init() + } + + public override func setup() { + markets = [Market(id: "1")] + } + + public func send(_ input: Input) { + switch input { + case .tapped(let market): callbacks.onOpen(market) + case .closeTapped: callbacks.onClose() + } + } + + public func load() async { + isLoading = true + markets = [Market(id: "1"), Market(id: "2")] + isLoading = false + } +} + +/// For the tests: runs the main run loop briefly, which drains the main queue as Android's main +/// looper does, so main-actor work can run while the JVM main thread waits. +public enum MainLoop { + @MainActor + public static func run(seconds: Double) { + _ = RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: seconds)) + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClassHierarchyTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClassHierarchyTest.kt new file mode 100644 index 000000000..153dbaf58 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClassHierarchyTest.kt @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +class ClassHierarchyTest { + @Test + fun subclassIsASuperclass() { + val dog = Dog.init("Rex") + val animal: Animal = dog + assertEquals("Rex says woof", animal.describe()) + assertEquals("Rex", animal.name) + assertEquals("stick", dog.fetch()) + } + + @Test + fun subclassPassedAsSuperclass() { + assertEquals("Rex says woof", Zoo.describe(Dog.init("Rex"))) + } + + @Test + fun returnedInstanceKeepsItsDynamicClass() { + val animal = Zoo.makeDog("Rex") + assertIs(animal) + assertEquals("stick", animal.fetch()) + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ViewModelTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ViewModelTest.kt new file mode 100644 index 000000000..27ec9bd6b --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ViewModelTest.kt @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +// A view model as Compose code uses it. Main-actor isolated, so macOS only (see MainActorTest). +@EnabledOnOs(OS.MAC) +class ViewModelTest { + @Test + fun viewModelLifecycle() { + val opened = mutableListOf() + var closed = false + val vm = MarketViewModel.init(MarketViewModel.Callbacks.init({ market -> opened += market.id }, { closed = true })) + val base: BaseViewModel = vm + base.setup() + assertEquals(1, vm.markets.size) + + // `load()` is main-actor isolated: pump the main loop instead of blocking the main thread on + // the future (on Android the main looper runs it while the caller suspends). + val loaded = vm.load() + val deadline = System.currentTimeMillis() + 10_000 + while (!loaded.isDone && System.currentTimeMillis() < deadline) MainLoop.run(0.01) + loaded.get() + assertFalse(vm.isLoading) + assertEquals(2, vm.markets.size) + + vm.send(MarketViewModel.Input.tapped(vm.markets[1])) + vm.send(MarketViewModel.Input.closeTapped()) + assertEquals(listOf("2"), opened) + assertEquals(true, closed) + } +}