Skip to content

Fix IllegalAccessException reading non-public PreferenceKey/EnvironmentKey (#243)#490

Open
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/preferencekey-private-reflect
Open

Fix IllegalAccessException reading non-public PreferenceKey/EnvironmentKey (#243)#490
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/preferencekey-private-reflect

Conversation

@vincentborko

Copy link
Copy Markdown
Contributor

Motivation

A custom PreferenceKey or EnvironmentKey declared private or fileprivate crashes the first time its value is read. On Android the key transpiles to a package-private Kotlin class, and reading its companion object via Kotlin reflection throws:

java.lang.IllegalAccessException: class kotlin.reflect.jvm.internal.KClassImpl$Data
cannot access a member of class skip.ui.<Key> with modifiers "public static final"

Root cause

PreferenceKey.swift and EnvironmentValues.swift read the companion via kotlin.reflect.full.companionObjectInstance:

  • Preference.init(key:)key.companionObjectInstance as! PreferenceKeyCompanion<Value>
  • View.onPreferenceChange(_:perform:)key.companionObjectInstance as PreferenceKeyCompanion<V>
  • EnvironmentValues.valueCompositionLocal(key:)key.companionObjectInstance as EnvironmentKeyCompanion<*>

The synthesized companion INSTANCE field is public static final, but the enclosing key class is package-private when the Swift type is private/fileprivate. Kotlin's companionObjectInstance reads that field without calling isAccessible = true, so the JVM access check fails with IllegalAccessException.

Fix

Introduce a small companionInstance(ofType:) helper that resolves the companion via Java reflection with field.isAccessible = true, and route the three call sites through it:

internal fun companionInstance(ofType: KClass<*>): Any? {
    val name = ofType.companionObject?.simpleName ?: "Companion"
    val field = ofType.java.getDeclaredField(name)
    field.isAccessible = true
    return field.get(null)
}

This is the minimal root-level fix — it removes the one missing isAccessible = true rather than forcing every custom key to be public.

Testing

The regression test was previously worked around in 3a7de72 ("Make test PreferenceKey implementation public, working around #243") by making the test EnvironmentKey and its accessor public. This PR reverts that to private/fileprivate, restoring testCustomEnvironmentValue as a genuine reproduction.

  • Before the fix (test key private): testCustomEnvironmentValue$SkipUI fails on Robolectric with the IllegalAccessException above — JUNIT ... PASSED 0 FAILED 1.
  • After the fix: JUNIT ... PASSED 1 FAILED 0.

Verified with the real Skip SDK via swift test (Robolectric): transpiles, compileDebugKotlin succeeds, and testCustomEnvironmentValue passes with the test key private.

Related

#310 reports what looks like the same root cause surfacing as a null cannot be cast to non-null type ...PreferenceKeyCompanion NPE (a non-public @AppStorage/preference key whose companion reflection fails). This change should resolve it too, though only #243 was reproduced here.

Fixes #243

@cla-bot cla-bot Bot added the cla-signed label Jul 24, 2026
…ntKey

A custom `PreferenceKey` or `EnvironmentKey` declared `private`/`fileprivate`
transpiles to a package-private Kotlin class. Reading its companion via Kotlin's
`companionObjectInstance` (kotlin.reflect.full) throws on first access:

    java.lang.IllegalAccessException: class kotlin.reflect.jvm.internal.KClassImpl$Data
    cannot access a member of class skip.ui.<Key> with modifiers "public static final"

The synthesized companion INSTANCE field is public, but its declaring class is
not, and Kotlin reflection reads the field without setting `isAccessible = true`.

Replace the three `companionObjectInstance` call sites (in PreferenceKey.swift
and EnvironmentValues.swift) with a `companionInstance(ofType:)` helper that
resolves the companion via Java reflection with `field.isAccessible = true`.

The regression test in 3a7de72 ("Make test PreferenceKey implementation public,
working around skiptools#243") sidestepped this by making the test EnvironmentKey public.
This restores it to `private`/`fileprivate`, so `testCustomEnvironmentValue`
again reproduces skiptools#243 and now passes with the fix.

skiptools#310 reports what appears to be the same root cause surfacing as a null-companion
cast NPE (a non-public `@AppStorage`/preference key whose companion reflection
fails); this change should resolve it as well, though only skiptools#243 was reproduced
here.

Fixes skiptools#243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vincentborko
vincentborko force-pushed the pr/preferencekey-private-reflect branch from 1495208 to a6a5e74 Compare July 24, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-public PreferenceKey implementations throw IllegalAccessException

1 participant