diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 0dc44bf7..ff1f3947 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.full.companionObject #endif public protocol EnvironmentKey { @@ -41,6 +41,18 @@ public protocol EnvironmentKeyCompanion { associatedtype Value var defaultValue: Value { get } } + +/// Companion object instance for `type`, via Java reflection with `isAccessible = true`. +/// Kotlin's `companionObjectInstance` omits that and throws `IllegalAccessException` for +/// non-`public` key types, whose generated class is package-private (issue #243). +func companionInstance(ofType type: Any.Type) -> Any? { + var instance: Any? = nil + // SKIP INSERT: val name = type.companionObject?.simpleName ?: "Companion" + // SKIP INSERT: val field = type.java.getDeclaredField(name) + // SKIP INSERT: field.isAccessible = true + // SKIP INSERT: instance = field.get(null) + return instance +} #endif // Model as a class because our implementation only holds the global environment keys, and so does not need to copy. @@ -145,7 +157,7 @@ public final class EnvironmentValues { /// The Compose `CompositionLocal` for the given environment value key type. public func valueCompositionLocal(key: Any.Type) -> ProvidableCompositionLocal { - // SKIP INSERT: val defaultValue = { (key.companionObjectInstance as EnvironmentKeyCompanion<*>).defaultValue } + // SKIP INSERT: val defaultValue = { (companionInstance(key) as EnvironmentKeyCompanion<*>).defaultValue } return compositionLocal(key: key, defaultValue: defaultValue) } diff --git a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift index 0d0911fe..9e91c747 100644 --- a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift +++ b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift @@ -19,7 +19,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable -import kotlin.reflect.full.companionObjectInstance import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged #endif @@ -137,7 +136,7 @@ struct PreferenceCollector { init(key: Any.Type, initialValue: Value? = nil) { self.key = key - let companion = key.companionObjectInstance as! PreferenceKeyCompanion + let companion = companionInstance(ofType: key) as! PreferenceKeyCompanion self.initialValue = initialValue ?? companion.defaultValue self.reducer = { value, nextValue in var updatedValue = value @@ -200,8 +199,8 @@ extension View { public func onPreferenceChange(_ key: K.Type /* = K.self */, perform action: @escaping (K.Value) -> Void) -> any View where K : PreferenceKey { #if SKIP // Work around transpiler bug - // SKIP REPLACE: val companion = key.companionObjectInstance as PreferenceKeyCompanion - let companion = key.companionObjectInstance as! PreferenceKeyCompanion + // SKIP REPLACE: val companion = companionInstance(key) as PreferenceKeyCompanion + let companion = companionInstance(ofType: key) as! PreferenceKeyCompanion return onPreferenceChange(key: key, defaultValue: companion.defaultValue, reducer: { value, nextValue in var updatedValue = value as! V companion.reduce(value: &updatedValue, nextValue: { nextValue as! V }) diff --git a/Tests/SkipUITests/SkipUITests.swift b/Tests/SkipUITests/SkipUITests.swift index 03fcdb89..88cb766b 100644 --- a/Tests/SkipUITests/SkipUITests.swift +++ b/Tests/SkipUITests/SkipUITests.swift @@ -845,6 +845,28 @@ final class SkipUITests: SkipUITestCase { } } + // Reading a non-public `PreferenceKey` reflects its companion the same way a + // non-public `EnvironmentKey` does. Composing `.onPreferenceChange` for a private + // key must not throw `IllegalAccessException` (issue #243); the label renders only + // if composition completes without that crash. + func testCustomPreferenceKey() throws { + try testUI(view: { + PreferenceValueView() + .accessibilityIdentifier("test-view") + }, eval: { rule in + try check(rule, id: "preference-label", hasText: "content") + }) + } + struct PreferenceValueView: View { + var body: some View { + VStack { + Text("content") + .accessibilityIdentifier("preference-label") + } + .onPreferenceChange(PreferenceTestKey.self) { _ in } + } + } + // func testObservability() throws { // try testUI(view: { // ObservablesOuterView() @@ -1005,13 +1027,21 @@ extension SemanticsNode { #endif // Used in `testCustomEnvironmentValue` -public struct EnvironmentValueTestKey: EnvironmentKey { - public static let defaultValue = "default" +private struct EnvironmentValueTestKey: EnvironmentKey { + static let defaultValue = "default" } extension EnvironmentValues { - public var testValue: String { + fileprivate var testValue: String { get { self[EnvironmentValueTestKey.self] } set { self[EnvironmentValueTestKey.self] = newValue } } } + +// Used in `testCustomPreferenceKey`; non-public to reproduce issue #243. +private struct PreferenceTestKey: PreferenceKey { + static let defaultValue = "preferred" + static func reduce(value: inout String, nextValue: () -> String) { + value = nextValue() + } +}