From 70361f2ab5a2053bc4091e46f1597a0df4469a42 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Wed, 15 Jul 2026 16:34:13 +0800 Subject: [PATCH 1/5] feat(java): support Fory JSON on Android --- .github/workflows/ci.yml | 7 +- docs/guide/java/android-support.md | 96 ++ docs/guide/java/graalvm-support.md | 17 +- docs/guide/java/json-support.md | 83 +- integration_tests/android_tests/README.md | 19 +- integration_tests/android_tests/build.gradle | 5 +- .../android_tests/proguard-rules.pro | 49 + .../android/ForyAndroidInstrumentedTest.java | 35 + .../fory/android/AndroidJsonScenarios.java | 162 ++ .../fory/android/GeneratedJsonContract.java | 33 + .../fory/android/GeneratedJsonModel.java | 203 +++ .../fory/android/GeneratedJsonParent.java | 32 + .../fory/android/GeneratedJsonSubtype.java | 27 + .../fory/android/GeneratedPlainJsonModel.java | 36 + .../apache/fory/android/ManualJsonModel.java | 143 ++ .../fory/android/ManualNestedModel.java | 92 ++ .../fory/android/ManualPlainJsonModel.java | 33 + .../fory/android/ReflectionJsonModel.java | 33 + .../fory/android/SuppressedJsonContract.java | 29 + .../apache/fory/graalvm/ForyJsonExample.java | 24 +- java/fory-annotation-processor/README.md | 17 +- java/fory-annotation-processor/pom.xml | 8 +- .../processing/ForyStructProcessor.java | 216 +-- .../processing/JavacTypeUseTrees.java | 219 +++ .../processing/JsonTypeProcessor.java | 1469 +++++++++++++++++ .../processing/JsonTypeProcessorTest.java | 439 +++++ java/fory-json/README.md | 92 +- .../org/apache/fory/json/ForyJsonBuilder.java | 18 +- .../fory/json/annotation/JsonCodec.java | 13 +- .../apache/fory/json/annotation/JsonType.java | 15 +- .../apache/fory/json/codec/ObjectCodec.java | 19 +- .../fory/json/codec/ObjectCodecBuilder.java | 117 +- .../json/meta/GeneratedJsonCodecMeta.java | 31 + .../apache/fory/json/meta/JsonTypeUse.java | 271 ++- .../json/resolver/JsonSharedRegistry.java | 164 +- .../fory/json/resolver/JsonTypeResolver.java | 4 +- .../json/codec/ForyJsonGraalVMFeature.java | 49 +- .../fory/json/JsonAndroidRuntimeTest.java | 327 ++++ .../fory/json/JsonCodecAnnotationTest.java | 55 +- .../fory/json/meta/JsonTypeUseTest.java | 58 +- 40 files changed, 4408 insertions(+), 351 deletions(-) create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonContract.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonParent.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonSubtype.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedPlainJsonModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualJsonModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualNestedModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualPlainJsonModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/ReflectionJsonModel.java create mode 100644 integration_tests/android_tests/src/main/java/org/apache/fory/android/SuppressedJsonContract.java create mode 100644 java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java create mode 100644 java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java create mode 100644 java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java create mode 100644 java/fory-json/src/main/java/org/apache/fory/json/meta/GeneratedJsonCodecMeta.java create mode 100644 java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d98bddbc17..840d212ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,7 +314,7 @@ jobs: - name: Install Fory Java artifacts run: | cd java - mvn -T16 --no-transfer-progress -pl fory-core,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true + mvn -T16 --no-transfer-progress -pl fory-json,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -329,7 +329,10 @@ jobs: working-directory: integration_tests/android_tests script: | yes | sdkmanager "platforms;android-${{ matrix.api-level }}" "build-tools;35.0.0" - gradle --no-daemon --stacktrace connectedCheck + if [[ "${{ matrix.api-level }}" == "26" ]]; then + gradle --no-daemon --stacktrace -PforyTestBuildType=debug connectedCheck + fi + gradle --no-daemon --stacktrace -PforyTestBuildType=release connectedCheck - name: Upload Android Test Report uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} diff --git a/docs/guide/java/android-support.md b/docs/guide/java/android-support.md index 7aede67a3c..5d119ad770 100644 --- a/docs/guide/java/android-support.md +++ b/docs/guide/java/android-support.md @@ -42,6 +42,101 @@ Android serialization on the non-codegen path and logs a warning. Android apps that need generated serializers should use build-time static generated serializers instead. +## Fory JSON + +Fory JSON supports ordinary classes on Android API level 26 and later through the regular +`fory-json` artifact. Runtime JSON code generation and asynchronous compilation are disabled +automatically, so `ForyJson.builder().build()` uses the interpreted object mapper. + +Add Fory JSON to the application and put the annotation processor on the module's processor path: + +```kotlin +dependencies { + implementation("org.apache.fory:fory-json:${foryVersion}") + annotationProcessor("org.apache.fory:fory-annotation-processor:${foryVersion}") +} +``` + +`@JsonType` is optional. Add it to application models when the processor should emit exact R8 rules +and metadata for `@JsonCodec` type uses, including qualified roots, parameter types, generic +arguments, and array components: + +```java +import java.util.List; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonType; + +@JsonType +public final class GeneratedInvoice { + public List<@JsonCodec(MoneyCodec.class) Money> items; + + public GeneratedInvoice() {} +} +``` + +Without `@JsonType`, ordinary reflection mapping and `@JsonCodec` declarations on types, fields, and +effective ordinary getters still work. A release-minified application must supply equivalent exact +R8 rules for every reflected class, constructor, field, method, generic signature, and runtime +annotation. For example: + +```java +public final class ManualInvoice { + @JsonCodec(MoneyCodec.class) + public Money total; + + public ManualInvoice() {} +} +``` + +```proguard +-keepattributes Signature,RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeVisibleTypeAnnotations,AnnotationDefault,MethodParameters +-keep,allowoptimization class com.example.ManualInvoice { + public (); + public com.example.Money total; +} +-keep,allowoptimization,allowobfuscation class com.example.MoneyCodec { + public (); +} +``` + +Application-authored rules cannot supply codec type-use metadata that Android reflection does not +expose. For example, this nested annotation is not applied on Android when the class omits +`@JsonType`, even if exact rules retain the class, field, generic signature, and type annotation: + +```java +public final class UnsupportedNestedInvoice { + public List<@JsonCodec(MoneyCodec.class) Money> items; + + public UnsupportedNestedInvoice() {} +} +``` + +```proguard +-keepattributes Signature,RuntimeVisibleTypeAnnotations +-keep,allowoptimization class com.example.UnsupportedNestedInvoice { + public (); + public java.util.List items; +} +``` + +On Android, a pure type-use `@JsonCodec` requires `@JsonType`. This includes a qualified root type +use, a parameter type, a generic argument, and an array component. Type declarations, field +declarations, and effective ordinary getter declarations remain available through direct +reflection without `@JsonType`. + +On the JVM and in a native image, a field or effective ordinary getter declaration takes precedence +over a codec on the root annotated type. Android reads the declaration directly and obtains pure +type-use facts from the generated companion. A setter, creator factory, unrelated method, or void +method cannot declare `@JsonCodec`. `JsonAnyProperty` and `JsonAnyGetter` flatten their Map rather +than exposing a complete root value, so their root declaration and root type-use codec forms are +invalid; annotate the Map value type when a nested codec is needed. + +Android Fory JSON requires a retained no-argument constructor for an ordinary mutable class; it may +be non-public when Android reflection can make it accessible. `JsonCreator` constructor-backed +classes follow the normal creator rules instead. Retain every field and method used for reflection, +or use an application codec when a model cannot satisfy those requirements. Fory JSON Record +mapping is not supported on Android. + ## Static Generated Serializers Use `@ForyStruct` static generated serializers for Android application classes. They are generated by @@ -115,6 +210,7 @@ The following JVM features are not supported on Android: - Native-address serialization APIs and native-address `MemoryBuffer` wrapping. - Raw unsafe memory copy APIs. - `java/fory-format` row-format APIs. +- Fory JSON Record mapping. ## ByteBuffer diff --git a/docs/guide/java/graalvm-support.md b/docs/guide/java/graalvm-support.md index 37095c1b52..953a340316 100644 --- a/docs/guide/java/graalvm-support.md +++ b/docs/guide/java/graalvm-support.md @@ -84,10 +84,19 @@ other builder options retain their normal behavior. Applications can create diff `ForyJson` instances at runtime and do not need build-time initialization or reflection configuration. -Declaration-level, inherited, and nested type-use `@JsonCodec` annotations are supported. An -annotation codec must have the same public no-argument constructor required on the JVM. In a named -module, export or open its package to `org.apache.fory.json`. A codec instance supplied through -`registerCodec` is constructed by the application and needs no annotation-constructor metadata. +Type, field, effective ordinary getter, inherited, and nested type-use `@JsonCodec` annotations are +supported. For a field or getter, the declaration is checked first and the root annotated type is +used only when the declaration is absent. Nested generic arguments and array components remain +fully supported in a native image. + +`JsonAnyProperty` and `JsonAnyGetter` flatten their Map and have no complete root value, so a field +or method declaration codec and a root type-use codec are invalid there. A nested codec on the Map +value remains supported. + +An annotation codec must have the same public no-argument constructor required on the JVM. In a +named module, export or open its package to `org.apache.fory.json`. A codec instance supplied +through `registerCodec` is constructed by the application and needs no annotation-constructor +metadata. ## Basic Usage diff --git a/docs/guide/java/json-support.md b/docs/guide/java/json-support.md index 1956b5b3c1..7167ad39c5 100644 --- a/docs/guide/java/json-support.md +++ b/docs/guide/java/json-support.md @@ -324,9 +324,10 @@ import org.apache.fory.json.annotation.JsonSubTypes; import org.apache.fory.json.annotation.JsonType; ``` -`JsonType` marks a reachable object model for GraalVM Native Image metadata. It has no effect on -ordinary JVM JSON behavior and is not inherited. See [GraalVM Support](graalvm-support.md) for the -complete native-image workflow. +`JsonType` marks a reachable object model for GraalVM Native Image metadata. On Android it also +enables processor-generated R8 rules and `JsonCodec` type-use metadata. It has no effect on ordinary +JVM JSON behavior and is not inherited. See [GraalVM Support](graalvm-support.md) and +[Android Support](android-support.md) for the platform workflows. ### `JsonProperty` @@ -738,6 +739,27 @@ The default applies at root `Class` and raw `TypeRef` targets and at unannotated positions. Fory explicitly inherits declarations through superclasses and interfaces; it does not use Java `@Inherited`. +On a field or effective ordinary getter, a declaration annotation selects the codec for that +member's root value: + +```java +public final class Invoice { + @JsonCodec(MoneyCodec.class) + public Money total; + + @JsonCodec(MoneyCodec.class) + public Money getTax() { + return tax; + } +} +``` + +The method form is also valid on an effective record accessor. It is invalid on setters, creator +factories, unrelated methods, and void methods. An ordinary getter declaration is invalid when +field mode disables getter discovery; record accessors continue to follow the existing record owner +path. For a selected field or getter, Fory checks the declaration first and uses the annotation on +the root annotated type only when the declaration is absent. + Use the same annotation at a type-use position to select a codec for exactly that occurrence: ```java @@ -749,7 +771,9 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; public final class Invoice { - public @JsonCodec(MoneyCodec.class) Money total; + // Qualified placement selects the root type-use fallback rather than the field declaration. + public com.example.@JsonCodec(MoneyCodec.class) Money qualifiedTotal; + public List<@JsonCodec(MoneyCodec.class) Money> items; public Set<@JsonCodec(MoneyCodec.class) Money> uniqueItems; public Collection<@JsonCodec(MoneyCodec.class) Money> allItems; @@ -758,7 +782,7 @@ public final class Invoice { public AtomicReference<@JsonCodec(MoneyCodec.class) Money> current; // Codec for each element. - public @JsonCodec(MoneyCodec.class) Money[] itemArray; + public com.example.@JsonCodec(MoneyCodec.class) Money[] itemArray; // Codec for the complete array value. public Money @JsonCodec(MoneyArrayCodec.class) [] encodedArray; @@ -793,17 +817,21 @@ discovered model property, declare a default on `Money`, or use exact builder re Fory chooses the complete codec for each current resolved value node using this exact order. An invalid higher-priority source fails rather than falling back. -| Priority | Source | Matching rule | -| -------: | --------------------------------------- | ------------------------------------------------------------------------- | -| 1 | Current `TYPE_USE @JsonCodec` | Exact resolved occurrence after property merging and generic substitution | -| 2 | `registerCodec(Target.class, instance)` | Exact target `Class`; never inherited | -| 3 | Direct `@JsonCodec` declaration | Current class, record, enum, or interface | -| 4 | Inherited `@JsonCodec` declaration | Most-specific superclass/interface result | -| 5 | Existing mapping | `JsonSubTypes`, built-in/container mapping, then default object mapping | +| Priority | Source | Matching rule | +| -------: | --------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| 1 | Current member or `TYPE_USE @JsonCodec` | Exact resolved occurrence after declaration-first acquisition, property merging, and generic substitution | +| 2 | `registerCodec(Target.class, instance)` | Exact target `Class`; never inherited | +| 3 | Direct type `@JsonCodec` declaration | Current class, record, enum, or interface | +| 4 | Inherited type declaration | Most-specific superclass/interface result | +| 5 | Existing mapping | `JsonSubTypes`, built-in/container mapping, then default object mapping | + +For each field or getter, its declaration takes precedence only over that same member's root +annotated type. The selected field, getter, setter, record component, and creator occurrences are +then merged as one logical property; different codecs at the same node remain a conflict. Rows 1 through 4 replace the whole current value mapping. They may therefore replace a scalar, -enum, container, object, or `JsonSubTypes` representation. A current type-use, exact registration, -or direct declaration also resolves any lower-priority inherited ambiguity. +enum, container, object, or `JsonSubTypes` representation. A current occurrence, exact +registration, or direct declaration also resolves any lower-priority inherited ambiguity. ### Deterministic inheritance and conflicts @@ -911,11 +939,13 @@ map values, Optional, and atomic references resolve their annotated children nor Map keys are JSON object member names, not JSON values. An explicit `@JsonCodec` anywhere in a Map key subtree is rejected. Builder or declaration value codecs on the key's raw type are ignored in -key position and still apply when that type is used as a value. For `JsonAnyProperty`, -`JsonAnyGetter`, and `JsonAnySetter`, the outer Map is flattened and cannot select a whole-value -codec; only its value node can. Wildcards, wildcard bounds, and type variables still unresolved -after substitution cannot select a codec. A type-use on an `extends` or `implements` clause is not -a JSON value occurrence and is not used; annotate the type declaration instead. Annotation type +key position and still apply when that type is used as a value. `JsonAnyProperty` and +`JsonAnyGetter` flatten their Map and cannot select a whole-value codec; only the nested Map value +node can. A field or method declaration codec and a root type-use codec are therefore invalid on +those two forms. A `JsonAnySetter` value parameter is already one flattened value and may carry a +root type-use codec. Wildcards, wildcard bounds, and type variables still unresolved after +substitution cannot select a codec. A type-use on an `extends` or `implements` clause is not a JSON +value occurrence and is not used; annotate the type declaration instead. Annotation type declarations are not supported JSON model targets. ### Construction, inherited results, and platform support @@ -934,11 +964,16 @@ succeed. Returning a plain parent while decoding the child fails with `ForyJsonE names the target type, codec class, declaring type, and actual returned type. The actual result, not the parent's `final` status or the codec's generic signature, determines validity. -`@JsonCodec` is supported on ordinary JVMs and GraalVM native images, including inherited -declarations and nested type uses. Native models must follow the `JsonType` workflow described in -[GraalVM Support](graalvm-support.md), which registers the annotation codec's public no-argument -constructor. Android ignores declaration and type-use forms; use exact `registerCodec` -registration there. +`@JsonCodec` is supported on ordinary JVMs and GraalVM native images, including member +declarations, inherited type declarations, and nested type uses. Native models must follow the +`JsonType` workflow described in [GraalVM Support](graalvm-support.md), which registers the +annotation codec's public no-argument constructor. + +Android directly reads type, field, and effective getter declarations. Pure type-use locations, +including qualified roots, parameters, generic arguments, and array components, require `JsonType` +processor metadata. `JsonType` also supplies automatic R8 rules; applications that omit it can +supply exact rules manually and use declaration syntax, but type-use codecs remain unavailable. See +[Android Support](android-support.md). ## Type validation and untrusted input diff --git a/integration_tests/android_tests/README.md b/integration_tests/android_tests/README.md index 17a4e87114..8580bf4795 100644 --- a/integration_tests/android_tests/README.md +++ b/integration_tests/android_tests/README.md @@ -1,19 +1,26 @@ # Android Integration Tests -This project runs Android API 26+ instrumented tests for Java `fory-core`. The -instrumented tests run against the release build type so R8/minification covers -the static generated serializer path. +This project runs Android API 26+ instrumented tests for Java `fory-core` and +`fory-json`. API 26 runs both debug reflection coverage and the release-minified +suite. API 36 runs the release-minified suite. Release coverage verifies static +serializers, processor-generated Fory JSON rules and codec type-use metadata, and +equivalent application-authored exact rules. -The tests consume `org.apache.fory:fory-core:1.4.0-SNAPSHOT` and +The tests consume `org.apache.fory:fory-core:1.4.0-SNAPSHOT`, +`org.apache.fory:fory-json:1.4.0-SNAPSHOT`, and `org.apache.fory:fory-annotation-processor:1.4.0-SNAPSHOT` from the local Maven repository, so install the Java artifacts before running Gradle: ```bash cd ../../java -mvn -T16 --no-transfer-progress -pl fory-core,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true +mvn -T16 --no-transfer-progress -pl fory-json,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true cd ../integration_tests/android_tests -gradle --no-daemon connectedCheck +gradle --no-daemon -PforyTestBuildType=debug connectedCheck +gradle --no-daemon -PforyTestBuildType=release connectedCheck ``` +The `foryTestBuildType` property is test-only. It selects the target build type +used by the instrumentation suite; production build configuration is unchanged. + `java/fory-format` is intentionally not covered here because it is not part of the Android support surface. diff --git a/integration_tests/android_tests/build.gradle b/integration_tests/android_tests/build.gradle index e797ae54d4..e6dd25601b 100644 --- a/integration_tests/android_tests/build.gradle +++ b/integration_tests/android_tests/build.gradle @@ -45,6 +45,8 @@ buildscript { apply plugin: 'com.android.application' +def foryTestBuildType = providers.gradleProperty('foryTestBuildType').getOrElse('release') + android { namespace 'org.apache.fory.android.tests' compileSdk 35 @@ -58,7 +60,7 @@ android { testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } - testBuildType 'release' + testBuildType foryTestBuildType buildTypes { release { @@ -86,6 +88,7 @@ dependencies { exclude group: 'com.google.guava', module: 'guava' exclude group: 'org.codehaus.janino', module: 'janino' } + implementation 'org.apache.fory:fory-json:1.4.0-SNAPSHOT' annotationProcessor 'org.apache.fory:fory-annotation-processor:1.4.0-SNAPSHOT' implementation 'com.google.guava:guava:32.1.2-android' implementation 'org.slf4j:slf4j-api:2.0.12' diff --git a/integration_tests/android_tests/proguard-rules.pro b/integration_tests/android_tests/proguard-rules.pro index e5f35b79e1..7eda16af25 100644 --- a/integration_tests/android_tests/proguard-rules.pro +++ b/integration_tests/android_tests/proguard-rules.pro @@ -4,3 +4,52 @@ # own code. -keep class org.apache.fory.android.AndroidForyRuntimeScenarios { *; } -keep class org.apache.fory.android.AndroidForyRuntimeScenarios$* { *; } + +# Instrumentation calls these target-APK entry points from a separate APK. The +# JSON fixture classes remain outside this keep so their own R8 rules are tested. +-keep,allowoptimization class org.apache.fory.android.AndroidJsonScenarios { + public static void plainReflectionWithoutRules(boolean); + public static void manualPlainRules(); + public static void generatedPlainRules(); + public static void manualCodecs(); + public static void nestedCodecNeedsJsonType(); + public static void generatedCodecs(); +} + +# Equivalent user-authored rules for models that deliberately omit @JsonType. +-keepattributes Signature,RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations +-keepattributes RuntimeVisibleTypeAnnotations,RuntimeInvisibleTypeAnnotations,AnnotationDefault,MethodParameters +-keepattributes InnerClasses,EnclosingMethod + +-keep,allowoptimization class org.apache.fory.android.ManualPlainJsonModel { + public (); + public int id; + public java.lang.String name; +} + +-keep,allowoptimization class org.apache.fory.android.ManualJsonModel { + public (); + public org.apache.fory.android.ManualJsonModel$DirectValue direct; + public org.apache.fory.android.ManualJsonModel$MemberValue declaredField; + private org.apache.fory.android.ManualJsonModel$MemberValue declaredProperty; + public org.apache.fory.android.ManualJsonModel$MemberValue getDeclaredProperty(); + public void setDeclaredProperty(org.apache.fory.android.ManualJsonModel$MemberValue); +} + +-keep,allowoptimization class org.apache.fory.android.ManualJsonModel$DirectValue +-keep,allowoptimization,allowobfuscation class org.apache.fory.android.ManualJsonModel$DirectValueCodec { + public (); +} +-keep,allowoptimization,allowobfuscation class org.apache.fory.android.ManualJsonModel$MemberValueCodec { + public (); +} + +-keep,allowoptimization class org.apache.fory.android.ManualNestedModel { + public (); + public java.util.List values; +} +-keep,allowoptimization class org.apache.fory.android.ManualNestedModel$NestedValue { + public (); + public java.lang.String text; +} diff --git a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java index 5ed264cd22..dc449e8a84 100644 --- a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java +++ b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java @@ -19,12 +19,47 @@ package org.apache.fory.android; +import android.content.pm.ApplicationInfo; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import org.junit.Test; import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class ForyAndroidInstrumentedTest { + @Test + public void plainJsonReflectionWithoutRules() { + ApplicationInfo applicationInfo = + InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationInfo(); + AndroidJsonScenarios.plainReflectionWithoutRules( + (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0); + } + + @Test + public void manualPlainJsonRules() { + AndroidJsonScenarios.manualPlainRules(); + } + + @Test + public void generatedPlainJsonRules() { + AndroidJsonScenarios.generatedPlainRules(); + } + + @Test + public void manualJsonCodecs() { + AndroidJsonScenarios.manualCodecs(); + } + + @Test + public void nestedJsonCodecNeedsJsonType() { + AndroidJsonScenarios.nestedCodecNeedsJsonType(); + } + + @Test + public void generatedJsonCodecs() { + AndroidJsonScenarios.generatedCodecs(); + } + @Test public void androidRuntimeDisablesCodegenAndUnsafeCopies() { AndroidForyRuntimeScenarios.androidRuntimeDisablesCodegenAndUnsafeCopies(); diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java new file mode 100644 index 0000000000..98f8959a90 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/AndroidJsonScenarios.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.Arrays; +import org.apache.fory.json.ForyJson; + +/** Android acceptance scenarios for reflection, generated metadata, and R8 retention. */ +public final class AndroidJsonScenarios { + private AndroidJsonScenarios() {} + + public static void plainReflectionWithoutRules(boolean debuggable) { + if (!debuggable) { + return; + } + ForyJson json = ForyJson.builder().build(); + ReflectionJsonModel value = new ReflectionJsonModel(26, "reflection"); + String encoded = json.toJson(value); + checkEquals("{\"id\":26,\"name\":\"reflection\"}", encoded); + ReflectionJsonModel decoded = json.fromJson(encoded, ReflectionJsonModel.class); + checkEquals(26, decoded.id); + checkEquals("reflection", decoded.name); + } + + public static void manualPlainRules() { + ForyJson json = ForyJson.builder().build(); + ManualPlainJsonModel value = new ManualPlainJsonModel(27, "manual-plain"); + String encoded = json.toJson(value); + checkEquals("{\"id\":27,\"name\":\"manual-plain\"}", encoded); + ManualPlainJsonModel decoded = json.fromJson(encoded, ManualPlainJsonModel.class); + checkEquals(27, decoded.id); + checkEquals("manual-plain", decoded.name); + } + + public static void generatedPlainRules() { + ForyJson json = ForyJson.builder().build(); + GeneratedPlainJsonModel value = new GeneratedPlainJsonModel(28, "generated-plain"); + String encoded = json.toJson(value); + checkEquals("{\"id\":28,\"name\":\"generated-plain\"}", encoded); + GeneratedPlainJsonModel decoded = json.fromJson(encoded, GeneratedPlainJsonModel.class); + checkEquals(28, decoded.id); + checkEquals("generated-plain", decoded.name); + } + + public static void manualCodecs() { + ForyJson json = ForyJson.builder().build(); + ManualJsonModel value = new ManualJsonModel(); + value.direct = new ManualJsonModel.DirectValue("direct"); + value.declaredField = new ManualJsonModel.MemberValue("field"); + value.setDeclaredProperty(new ManualJsonModel.MemberValue("method")); + + ManualJsonModel.resetCodecCalls(); + String encoded = json.toJson(value); + check(encoded.contains("\"direct\":\"manual:direct\"")); + check(encoded.contains("\"declaredField\":\"manual:field\"")); + check(encoded.contains("\"declaredProperty\":\"manual:method\"")); + + ManualJsonModel decoded = json.fromJson(encoded, ManualJsonModel.class); + checkEquals("manual:direct", decoded.direct.text); + checkEquals("manual:field", decoded.declaredField.text); + checkEquals("manual:method", decoded.getDeclaredProperty().text); + checkEquals(6, ManualJsonModel.codecCalls()); + } + + public static void nestedCodecNeedsJsonType() { + ForyJson json = ForyJson.builder().build(); + ManualNestedModel value = new ManualNestedModel(); + value.values.add(new ManualNestedModel.NestedValue("plain")); + + ManualNestedModel.resetCodecCalls(); + String encoded = json.toJson(value); + checkEquals("{\"values\":[{\"text\":\"plain\"}]}", encoded); + ManualNestedModel decoded = json.fromJson(encoded, ManualNestedModel.class); + checkEquals("plain", decoded.values.get(0).text); + checkEquals(0, ManualNestedModel.codecCalls()); + } + + public static void generatedCodecs() { + ForyJson json = ForyJson.builder().build(); + GeneratedJsonSubtype value = new GeneratedJsonSubtype(); + value.subtypeId = 29; + value.values.add(new GeneratedJsonModel.Value("list")); + value.rootValue = new GeneratedJsonModel.Value("root"); + value.setRootProperty(new GeneratedJsonModel.Value("property")); + value.array = new GeneratedJsonModel.Value[] {new GeneratedJsonModel.Value("array")}; + value.byName.put("map", new GeneratedJsonModel.Value("map")); + value.inheritedValues.add(new GeneratedJsonModel.Value("parent")); + value.replaceInterfaceValues(Arrays.asList(new GeneratedJsonModel.Value("interface"))); + value.replaceSuppressedValues(Arrays.asList(new GeneratedJsonModel.PlainValue("plain"))); + value.extra.put("dynamic", new GeneratedJsonModel.Value("extra")); + + GeneratedJsonModel.resetCodecCalls(); + String encoded = json.toJson(value, GeneratedJsonModel.class); + check(encoded.contains("\"kind\":\"generated\"")); + check(encoded.contains("\"generated:list\"")); + check(encoded.contains("\"generated:root\"")); + check(encoded.contains("\"generated:property\"")); + check(encoded.contains("\"generated:array\"")); + check(encoded.contains("\"generated:map\"")); + check(encoded.contains("\"generated:parent\"")); + check(encoded.contains("\"generated:interface\"")); + check(encoded.contains("\"suppressedValues\":[{\"text\":\"plain\"}]")); + check(encoded.contains("\"dynamic\":\"generated:extra\"")); + + GeneratedJsonModel decoded = json.fromJson(encoded, GeneratedJsonModel.class); + check(decoded instanceof GeneratedJsonSubtype); + GeneratedJsonSubtype subtype = (GeneratedJsonSubtype) decoded; + checkEquals(29, subtype.subtypeId); + checkEquals("generated:list", subtype.values.get(0).text); + checkEquals("generated:root", subtype.rootValue.text); + checkEquals("generated:property", subtype.getRootProperty().text); + checkEquals("generated:array", subtype.array[0].text); + checkEquals("generated:map", subtype.byName.get("map").text); + checkEquals("generated:parent", subtype.inheritedValues.get(0).text); + checkEquals("generated:interface", subtype.getInterfaceValues().get(0).text); + checkEquals("plain", subtype.getSuppressedValues().get(0).text); + checkEquals("generated:extra", subtype.extra.get("dynamic").text); + + GeneratedJsonModel.CreatedValue created = + new GeneratedJsonModel.CreatedValue( + Arrays.asList(new GeneratedJsonModel.Value("creator")), + new GeneratedJsonModel.Value("direct")); + String creatorJson = json.toJson(created); + check(creatorJson.contains("\"values\":[\"generated:creator\"]")); + check(creatorJson.contains("\"direct\":\"generated:direct\"")); + GeneratedJsonModel.CreatedValue decodedCreated = + json.fromJson(creatorJson, GeneratedJsonModel.CreatedValue.class); + checkEquals("generated:creator", decodedCreated.values.get(0).text); + checkEquals("generated:direct", decodedCreated.direct.text); + checkEquals(20, GeneratedJsonModel.codecCalls()); + checkEquals(0, GeneratedJsonModel.suppressedCodecCalls()); + } + + private static void check(boolean condition) { + if (!condition) { + throw new AssertionError("check failed"); + } + } + + private static void checkEquals(Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError("expected " + expected + " but got " + actual); + } + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonContract.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonContract.java new file mode 100644 index 0000000000..730235447a --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonContract.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.List; +import org.apache.fory.json.annotation.JsonCodec; + +/** Unannotated interface metadata covered by {@link GeneratedJsonModel}'s companion. */ +public interface GeneratedJsonContract { + default List + getInterfaceValues() { + return interfaceValuesStorage(); + } + + List interfaceValuesStorage(); +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonModel.java new file mode 100644 index 0000000000..1389aed265 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonModel.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fory.json.annotation.JsonAnyProperty; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonProperty; +import org.apache.fory.json.annotation.JsonSubTypes; +import org.apache.fory.json.annotation.JsonType; +import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; + +/** Processor-generated nested codec and R8 metadata fixture. */ +@JsonType +@JsonSubTypes( + property = "kind", + value = {@JsonSubTypes.Type(value = GeneratedJsonSubtype.class, name = "generated")}) +public abstract class GeneratedJsonModel extends GeneratedJsonParent + implements GeneratedJsonContract, SuppressedJsonContract { + private static final AtomicInteger CODEC_CALLS = new AtomicInteger(); + private static final AtomicInteger SUPPRESSED_CODEC_CALLS = new AtomicInteger(); + + public List<@JsonCodec(ValueCodec.class) Value> values = new ArrayList<>(); + public GeneratedJsonModel.@JsonCodec(ValueCodec.class) Value rootValue; + public GeneratedJsonModel.@JsonCodec(ValueCodec.class) Value[] array; + public Map byName = new LinkedHashMap<>(); + + @JsonAnyProperty + public Map extra = new LinkedHashMap<>(); + + private List interfaceValues = new ArrayList<>(); + private List suppressedValues = new ArrayList<>(); + private Value rootProperty; + + public GeneratedJsonModel() {} + + @Override + public List interfaceValuesStorage() { + return interfaceValues; + } + + public void replaceInterfaceValues(List values) { + interfaceValues = values; + } + + @Override + public List getSuppressedValues() { + return suppressedValues; + } + + public void replaceSuppressedValues(List values) { + suppressedValues = values; + } + + public GeneratedJsonModel.@JsonCodec(ValueCodec.class) Value getRootProperty() { + return rootProperty; + } + + public void setRootProperty(Value rootProperty) { + this.rootProperty = rootProperty; + } + + public static void resetCodecCalls() { + CODEC_CALLS.set(0); + SUPPRESSED_CODEC_CALLS.set(0); + } + + public static int codecCalls() { + return CODEC_CALLS.get(); + } + + public static int suppressedCodecCalls() { + return SUPPRESSED_CODEC_CALLS.get(); + } + + public static final class Value { + public final String text; + + public Value(String text) { + this.text = text; + } + } + + @JsonType + public static final class PlainValue { + public String text; + + public PlainValue() {} + + public PlainValue(String text) { + this.text = text; + } + } + + public static final class ValueCodec implements JsonValueCodec { + public ValueCodec() {} + + @Override + public void writeString(StringJsonWriter writer, Value value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "generated:" + value.text); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, Value value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "generated:" + value.text); + } + + @Override + public Value readLatin1(Latin1JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new Value(reader.readString()); + } + + @Override + public Value readUtf16(Utf16JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new Value(reader.readString()); + } + + @Override + public Value readUtf8(Utf8JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new Value(reader.readString()); + } + } + + public static final class SuppressedValueCodec implements JsonValueCodec { + public SuppressedValueCodec() {} + + @Override + public void writeString(StringJsonWriter writer, PlainValue value) { + SUPPRESSED_CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "suppressed:" + value.text); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, PlainValue value) { + SUPPRESSED_CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "suppressed:" + value.text); + } + + @Override + public PlainValue readLatin1(Latin1JsonReader reader) { + SUPPRESSED_CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new PlainValue(reader.readString()); + } + + @Override + public PlainValue readUtf16(Utf16JsonReader reader) { + SUPPRESSED_CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new PlainValue(reader.readString()); + } + + @Override + public PlainValue readUtf8(Utf8JsonReader reader) { + SUPPRESSED_CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new PlainValue(reader.readString()); + } + } + + @JsonType + public static final class CreatedValue { + public final List values; + public final Value direct; + + @JsonCreator + public CreatedValue( + @JsonProperty("values") List<@JsonCodec(ValueCodec.class) Value> values, + @JsonProperty("direct") GeneratedJsonModel.@JsonCodec(ValueCodec.class) Value direct) { + this.values = values; + this.direct = direct; + } + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonParent.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonParent.java new file mode 100644 index 0000000000..cf0fabc016 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonParent.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.ArrayList; +import java.util.List; +import org.apache.fory.json.annotation.JsonCodec; + +/** Unannotated hierarchy owner covered by {@link GeneratedJsonModel}'s companion. */ +public class GeneratedJsonParent { + public List + inheritedValues = new ArrayList<>(); + + public GeneratedJsonParent() {} +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonSubtype.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonSubtype.java new file mode 100644 index 0000000000..34c64d3bbe --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedJsonSubtype.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +/** Class-literal subtype reached from {@link GeneratedJsonModel}. */ +public final class GeneratedJsonSubtype extends GeneratedJsonModel { + public int subtypeId; + + public GeneratedJsonSubtype() {} +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedPlainJsonModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedPlainJsonModel.java new file mode 100644 index 0000000000..d29cef0a44 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/GeneratedPlainJsonModel.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import org.apache.fory.json.annotation.JsonType; + +/** Plain JSON model retained by processor-generated R8 rules. */ +@JsonType +public final class GeneratedPlainJsonModel { + public int id; + public String name; + + public GeneratedPlainJsonModel() {} + + public GeneratedPlainJsonModel(int id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualJsonModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualJsonModel.java new file mode 100644 index 0000000000..25814c7f51 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualJsonModel.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; + +/** Declaration-codec fixture retained by application-authored exact R8 rules. */ +public final class ManualJsonModel { + private static final AtomicInteger CODEC_CALLS = new AtomicInteger(); + + public DirectValue direct; + + @JsonCodec(MemberValueCodec.class) + public MemberValue declaredField; + + private MemberValue declaredProperty; + + public ManualJsonModel() {} + + @JsonCodec(MemberValueCodec.class) + public MemberValue getDeclaredProperty() { + return declaredProperty; + } + + public void setDeclaredProperty(MemberValue declaredProperty) { + this.declaredProperty = declaredProperty; + } + + public static void resetCodecCalls() { + CODEC_CALLS.set(0); + } + + public static int codecCalls() { + return CODEC_CALLS.get(); + } + + public interface TextValue { + String text(); + } + + @JsonCodec(DirectValueCodec.class) + public static final class DirectValue implements TextValue { + public final String text; + + public DirectValue(String text) { + this.text = text; + } + + @Override + public String text() { + return text; + } + } + + public static final class MemberValue implements TextValue { + public final String text; + + public MemberValue(String text) { + this.text = text; + } + + @Override + public String text() { + return text; + } + } + + public abstract static class CountingCodec implements JsonValueCodec { + protected abstract T create(String text); + + @Override + public void writeString(StringJsonWriter writer, T value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "manual:" + value.text()); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, T value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "manual:" + value.text()); + } + + @Override + public T readLatin1(Latin1JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : create(reader.readString()); + } + + @Override + public T readUtf16(Utf16JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : create(reader.readString()); + } + + @Override + public T readUtf8(Utf8JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : create(reader.readString()); + } + } + + public static final class DirectValueCodec extends CountingCodec { + public DirectValueCodec() {} + + @Override + protected DirectValue create(String text) { + return new DirectValue(text); + } + } + + public static final class MemberValueCodec extends CountingCodec { + public MemberValueCodec() {} + + @Override + protected MemberValue create(String text) { + return new MemberValue(text); + } + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualNestedModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualNestedModel.java new file mode 100644 index 0000000000..cc3a4a2a21 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualNestedModel.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; + +/** No-JsonType fixture whose nested codec is intentionally unavailable on Android. */ +public final class ManualNestedModel { + private static final AtomicInteger CODEC_CALLS = new AtomicInteger(); + + public List<@JsonCodec(NestedValueCodec.class) NestedValue> values = new ArrayList<>(); + + public ManualNestedModel() {} + + public static void resetCodecCalls() { + CODEC_CALLS.set(0); + } + + public static int codecCalls() { + return CODEC_CALLS.get(); + } + + public static final class NestedValue { + public String text; + + public NestedValue() {} + + public NestedValue(String text) { + this.text = text; + } + } + + public static final class NestedValueCodec implements JsonValueCodec { + public NestedValueCodec() {} + + @Override + public void writeString(StringJsonWriter writer, NestedValue value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "nested:" + value.text); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, NestedValue value) { + CODEC_CALLS.incrementAndGet(); + writer.writeString(value == null ? null : "nested:" + value.text); + } + + @Override + public NestedValue readLatin1(Latin1JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new NestedValue(reader.readString()); + } + + @Override + public NestedValue readUtf16(Utf16JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new NestedValue(reader.readString()); + } + + @Override + public NestedValue readUtf8(Utf8JsonReader reader) { + CODEC_CALLS.incrementAndGet(); + return reader.tryReadNullToken() ? null : new NestedValue(reader.readString()); + } + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualPlainJsonModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualPlainJsonModel.java new file mode 100644 index 0000000000..0bdfcc49fc --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ManualPlainJsonModel.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +/** Plain JSON model retained by application-authored exact R8 rules. */ +public final class ManualPlainJsonModel { + public int id; + public String name; + + public ManualPlainJsonModel() {} + + public ManualPlainJsonModel(int id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/ReflectionJsonModel.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ReflectionJsonModel.java new file mode 100644 index 0000000000..6fc254ea96 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/ReflectionJsonModel.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +/** Plain debug-only JSON model with no processor metadata or R8 rules. */ +public final class ReflectionJsonModel { + public int id; + public String name; + + public ReflectionJsonModel() {} + + public ReflectionJsonModel(int id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/integration_tests/android_tests/src/main/java/org/apache/fory/android/SuppressedJsonContract.java b/integration_tests/android_tests/src/main/java/org/apache/fory/android/SuppressedJsonContract.java new file mode 100644 index 0000000000..93cfd878f1 --- /dev/null +++ b/integration_tests/android_tests/src/main/java/org/apache/fory/android/SuppressedJsonContract.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.android; + +import java.util.List; +import org.apache.fory.json.annotation.JsonCodec; + +/** Interface codec metadata suppressed by the target's unannotated override. */ +public interface SuppressedJsonContract { + List + getSuppressedValues(); +} diff --git a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java index 926608d5dd..d3665b1a2a 100644 --- a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java +++ b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java @@ -125,6 +125,8 @@ private static void testCodecs() { value.array = new TypeUseValue[] {new TypeUseValue("array")}; value.mapped = Map.of("key", new TypeUseValue("mapped")); value.property = new TypeUseValue("property"); + value.declaredField = new TypeUseValue("declared-field"); + value.declaredProperty = new TypeUseValue("declared-property"); value.record = new CodecRecord(new TypeUseValue("record")); value.creator = new CodecCreator(new TypeUseValue("creator")); value.factory = CodecFactory.create(new TypeUseValue("factory")); @@ -135,6 +137,8 @@ private static void testCodecs() { Preconditions.checkArgument(stringJson.contains("string:nested")); Preconditions.checkArgument(stringJson.contains("string:array")); Preconditions.checkArgument(stringJson.contains("string:mapped")); + Preconditions.checkArgument(stringJson.contains("string:declared-field")); + Preconditions.checkArgument(stringJson.contains("string:declared-property")); String utf8Json = new String(json.toJsonBytes(value), StandardCharsets.UTF_8); Preconditions.checkArgument(utf8Json.contains("utf8:direct")); Preconditions.checkArgument(utf8Json.contains("utf8:property")); @@ -154,6 +158,8 @@ private static void testCodecs() { checkStringRead(decoded.array[0].text, "string:array"); checkStringRead(decoded.mapped.get("key").text, "string:mapped"); checkStringRead(decoded.property.text, "string:property"); + checkStringRead(decoded.declaredField.text, "string:declared-field"); + checkStringRead(decoded.declaredProperty.text, "string:declared-property"); checkStringRead(decoded.record.value.text, "string:record"); checkStringRead(decoded.creator.value.text, "string:creator"); checkStringRead(decoded.factory.value.text, "string:factory"); @@ -380,7 +386,7 @@ public static final class ConfigValue { public String camelName; public String nullValue; - public @JsonCodec(IgnoredCodec.class) TypeUseValue ignoredCodecMethod() { + public ForyJsonExample.@JsonCodec(IgnoredCodec.class) TypeUseValue ignoredCodecMethod() { throw new AssertionError("Non-property methods must not participate in JSON metadata"); } } @@ -390,9 +396,14 @@ public static final class CodecModel { public DirectValue direct; public InheritedValue inherited; public List<@JsonCodec(TypeUseCodec.class) TypeUseValue> nested = new ArrayList<>(); - public @JsonCodec(TypeUseCodec.class) TypeUseValue[] array; + public ForyJsonExample.@JsonCodec(TypeUseCodec.class) TypeUseValue[] array; public Map mapped; private TypeUseValue property; + + @JsonCodec(TypeUseCodec.class) + public TypeUseValue declaredField; + + private TypeUseValue declaredProperty; public CodecRecord record; public CodecCreator creator; public CodecFactory factory; @@ -404,6 +415,15 @@ public static final class CodecModel { public void setProperty(@JsonCodec(TypeUseCodec.class) TypeUseValue property) { this.property = property; } + + @JsonCodec(TypeUseCodec.class) + public TypeUseValue getDeclaredProperty() { + return declaredProperty; + } + + public void setDeclaredProperty(TypeUseValue declaredProperty) { + this.declaredProperty = declaredProperty; + } } @JsonCodec(DirectCodec.class) diff --git a/java/fory-annotation-processor/README.md b/java/fory-annotation-processor/README.md index e56e4e8dfa..ce65b77281 100644 --- a/java/fory-annotation-processor/README.md +++ b/java/fory-annotation-processor/README.md @@ -1,8 +1,9 @@ # Fory Annotation Processor `fory-annotation-processor` generates static serializers for Java classes annotated with -`@ForyStruct`. The serializers are generated by javac during the application build, so they can be -used when runtime serializer generation is disabled or unavailable. +`@ForyStruct`. It also generates exact Android R8 rules for Fory JSON classes annotated with +`@JsonType`, plus metadata for `@JsonCodec` type uses. The outputs are generated by javac +during the application build. Use this processor for: @@ -10,14 +11,20 @@ Use this processor for: - ordinary JVM applications using `ForyBuilder#withCodegen(false)`. - Android model classes that use Fory type-use annotations such as `@Ref`, `@UInt8Type`, or `@Float16Type`. +- Android Fory JSON models that need automatic R8 rules or nested `@JsonCodec` support. For GraalVM native images, use Fory's GraalVM native-image build-time serializer generation instead. ## Ownership -The processor is an opt-in build tool. Applications add it to their annotation-processor path; it -emits Java source that references `fory-core` runtime APIs. `fory-core` does not depend on this -module, and the processor implementation does not need a compile-time dependency on `fory-core`. +The processor is an opt-in build tool. Applications add it to their annotation-processor path. The +processor has no production dependency on `fory-core` or `fory-json`; generated sources compile +against the corresponding runtime artifact already used by the application. + +For Fory JSON on Android, `@JsonType` is optional. A model without it can use application-authored +exact R8 rules, and type, field, and effective getter `@JsonCodec` declarations remain available +through reflection. Pure type-use codecs require `@JsonType` processor metadata. See +[`docs/guide/java/android-support.md`](../../docs/guide/java/android-support.md). For the full user-facing guide, see [`docs/guide/java/static-generated-serializers.md`](../../docs/guide/java/static-generated-serializers.md). diff --git a/java/fory-annotation-processor/pom.xml b/java/fory-annotation-processor/pom.xml index 137f2b3a5f..c3ae19e27c 100644 --- a/java/fory-annotation-processor/pom.xml +++ b/java/fory-annotation-processor/pom.xml @@ -32,7 +32,7 @@ fory-annotation-processor - Apache Fory annotation processor for build-time @ForyStruct serializer generation. + Apache Fory annotation processor for static serializers and JSON reflection metadata. @@ -42,6 +42,12 @@ + + org.apache.fory + fory-json + ${project.version} + test + org.apache.fory fory-core diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java index b529274b5c..162552301e 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java @@ -21,7 +21,6 @@ import java.io.IOException; import java.io.Writer; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -76,6 +75,7 @@ public final class ForyStructProcessor extends AbstractProcessor { private static final String FORY_DEBUG = annotationClass("ForyDebug"); private static final String FORY_FIELD = annotationClass("ForyField"); private static final String FORY_STRUCT = annotationClass("ForyStruct"); + private static final String JSON_TYPE = "org.apache.fory.json.annotation.JsonType"; private static final String IGNORE = annotationClass("Ignore"); private static final String INT32_TYPE = annotationClass("Int32Type"); private static final String INT64_TYPE = annotationClass("Int64Type"); @@ -89,20 +89,20 @@ public final class ForyStructProcessor extends AbstractProcessor { private static final String UINT8_TYPE = annotationClass("UInt8Type"); private static final Set SUPPORTED_ANNOTATIONS = supportedAnnotations(); - private final Set processed = new HashSet<>(); + private final Set processedStructs = new HashSet<>(); private final Map generatedTypes = new HashMap<>(); private Messager messager; private Filer filer; private Elements elements; private javax.lang.model.util.Types types; - // JDK 8 javac can omit nested TYPE_USE annotations from TypeMirror, so recover them from - // javac's public tree API reflectively while keeping this processor targetable to Java 8. - private Object trees; + private JavacTypeUseTrees typeUseTrees; + private JsonTypeProcessor jsonTypeProcessor; private static Set supportedAnnotations() { Set annotations = new HashSet<>(); annotations.add(FORY_STRUCT); annotations.add(FORY_DEBUG); + annotations.add(JSON_TYPE); return Collections.unmodifiableSet(annotations); } @@ -141,18 +141,8 @@ public synchronized void init(ProcessingEnvironment processingEnv) { filer = processingEnv.getFiler(); elements = processingEnv.getElementUtils(); types = processingEnv.getTypeUtils(); - try { - ClassLoader javacLoader = processingEnv.getClass().getClassLoader(); - Class treesClass = - Class.forName( - "com.sun.source.util.Trees", - false, - javacLoader == null ? ClassLoader.getSystemClassLoader() : javacLoader); - trees = - treesClass.getMethod("instance", ProcessingEnvironment.class).invoke(null, processingEnv); - } catch (ReflectiveOperationException | IllegalArgumentException e) { - trees = null; - } + typeUseTrees = new JavacTypeUseTrees(processingEnv); + jsonTypeProcessor = new JsonTypeProcessor(processingEnv, typeUseTrees); } @Override @@ -163,36 +153,36 @@ public SourceVersion getSupportedSourceVersion() { @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { TypeElement foryStruct = elements.getTypeElement(FORY_STRUCT); - if (foryStruct == null) { - return false; - } - for (Element element : roundEnv.getElementsAnnotatedWith(foryStruct)) { - if (!(element instanceof TypeElement)) { - continue; - } - TypeElement type = (TypeElement) element; - if (isKotlinClass(type)) { - continue; - } - String binaryName = elements.getBinaryName(type).toString(); - if (!processed.add(binaryName)) { - continue; - } - try { - List structs = buildStructs(type); - for (SourceStruct struct : structs) { - emit(struct, type); + if (foryStruct != null) { + for (Element element : roundEnv.getElementsAnnotatedWith(foryStruct)) { + if (!(element instanceof TypeElement)) { + continue; + } + TypeElement type = (TypeElement) element; + if (isKotlinClass(type)) { + continue; + } + String binaryName = elements.getBinaryName(type).toString(); + if (!processedStructs.add(binaryName)) { + continue; + } + try { + List structs = buildStructs(type); + for (SourceStruct struct : structs) { + emit(struct, type); + } + emitR8Rules(type, structs); + } catch (InvalidStructException e) { + messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), e.element); + } catch (RuntimeException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to generate Fory static serializer for " + binaryName + ": " + e.getMessage(), + type); } - emitR8Rules(type, structs); - } catch (InvalidStructException e) { - messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), e.element); - } catch (RuntimeException e) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "Failed to generate Fory static serializer for " + binaryName + ": " + e.getMessage(), - type); } } + jsonTypeProcessor.process(roundEnv); return true; } @@ -524,7 +514,7 @@ private boolean fieldNullable(TypeMirror type, Object tree, SerializerMode mode) if (type.getKind().isPrimitive()) { return false; } - if (typeUseAnnotation(type, typeTreeInfo(tree).annotations, NULLABLE) != null) { + if (typeUseAnnotation(type, typeUseTrees.tree(tree).annotations, NULLABLE) != null) { return true; } if (mode == SerializerMode.NATIVE) { @@ -534,7 +524,8 @@ private boolean fieldNullable(TypeMirror type, Object tree, SerializerMode mode) } private boolean fieldTrackingRef(VariableElement field, Object tree) { - TypeUseAnnotation ref = typeUseAnnotation(field.asType(), typeTreeInfo(tree).annotations, REF); + TypeUseAnnotation ref = + typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF); if (ref == null) { AnnotationMirror fieldRef = annotationMirror(field, REF); ref = fieldRef == null ? null : new TypeUseAnnotation(fieldRef, null); @@ -546,7 +537,7 @@ private boolean fieldHasTrackingRefMetadata(VariableElement field, Object tree) if (hasAnnotation(field, REF)) { return true; } - return typeUseAnnotation(field.asType(), typeTreeInfo(tree).annotations, REF) != null; + return typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF) != null; } private boolean isOptionalType(TypeMirror type) { @@ -770,18 +761,7 @@ private SourceTypeNode buildFieldTypeNode( } private Object typeTree(VariableElement field) { - if (trees == null) { - return null; - } - Object path = invoke(trees, "getPath", new Class[] {Element.class}, field); - if (path == null) { - return null; - } - Object leaf = invoke(path, "getLeaf"); - if (!isInstance("com.sun.source.tree.VariableTree", leaf)) { - return null; - } - return invoke(leaf, "getType"); + return typeUseTrees.typeTree(field); } private SourceTypeNode buildTypeNode(TypeMirror type) { @@ -795,7 +775,7 @@ private SourceTypeNode buildTypeNode( Element errorElement, boolean arrayComponent) { TypeKind kind = type.getKind(); - TypeTreeInfo treeInfo = typeTreeInfo(tree); + JavacTypeUseTrees.Tree treeInfo = typeUseTrees.tree(tree); if (kind == TypeKind.TYPEVAR) { TypeVariable typeVariable = (TypeVariable) type; return buildTypeNode( @@ -842,16 +822,6 @@ private SourceTypeNode buildTypeNode( rawType, typeName(type), extMeta, arguments, componentType, primitive, nestedStruct); } - private TypeTreeInfo typeTreeInfo(Object tree) { - List annotations = Collections.emptyList(); - Object current = tree; - while (isInstance("com.sun.source.tree.AnnotatedTypeTree", current)) { - annotations = listValue(invoke(current, "getAnnotations")); - current = invoke(current, "getUnderlyingType"); - } - return new TypeTreeInfo(annotations, current); - } - private boolean isCompatibleForyStructType(TypeMirror type) { TypeMirror erased = types.erasure(type); Element element = types.asElement(erased); @@ -1049,7 +1019,7 @@ private TypeUseAnnotation typeUseAnnotation( return new TypeUseAnnotation(mirror, null); } for (Object annotationTree : treeAnnotations) { - if (isAnnotationTree(annotationTree, annotationName)) { + if (typeUseTrees.isAnnotation(annotationTree, annotationName)) { return new TypeUseAnnotation(null, annotationTree); } } @@ -1068,20 +1038,6 @@ private AnnotationMirror typeAnnotationMirror(TypeMirror type, String annotation return annotationMirror(componentType, annotationName); } - private boolean isAnnotationTree(Object annotationTree, String annotationName) { - Object annotationType = invoke(annotationTree, "getAnnotationType"); - if (annotationType == null) { - return false; - } - String treeName = annotationType.toString(); - return treeName.equals(annotationName) || treeName.equals(simpleName(annotationName)); - } - - private String simpleName(String className) { - int index = className.lastIndexOf('.'); - return index < 0 ? className : className.substring(index + 1); - } - private AnnotationMirror annotationMirror(TypeMirror type, String annotationName) { for (AnnotationMirror mirror : type.getAnnotationMirrors()) { Element element = mirror.getAnnotationType().asElement(); @@ -1113,7 +1069,8 @@ private boolean booleanValue(TypeUseAnnotation annotation, String name, boolean return defaultValue; } if (annotation.mirror == null) { - return Boolean.parseBoolean(treeAnnotationValue(annotation.tree, name, defaultValue)); + return Boolean.parseBoolean( + typeUseTrees.annotationValue(annotation.tree, name, defaultValue)); } AnnotationMirror mirror = annotation.mirror; for (Map.Entry entry : @@ -1138,7 +1095,7 @@ private String enumValue(TypeUseAnnotation annotation, String name, String defau return defaultValue; } if (annotation.mirror == null) { - return enumConstant(treeAnnotationValue(annotation.tree, name, defaultValue)); + return enumConstant(typeUseTrees.annotationValue(annotation.tree, name, defaultValue)); } AnnotationMirror mirror = annotation.mirror; for (Map.Entry entry : @@ -1150,96 +1107,11 @@ private String enumValue(TypeUseAnnotation annotation, String name, String defau return defaultValue; } - private String treeAnnotationValue(Object annotationTree, String name, Object defaultValue) { - for (Object argument : listValue(invoke(annotationTree, "getArguments"))) { - Object valueTree = argument; - if (isInstance("com.sun.source.tree.AssignmentTree", argument)) { - Object variable = invoke(argument, "getVariable"); - if (variable == null || !variable.toString().equals(name)) { - continue; - } - valueTree = invoke(argument, "getExpression"); - } - return valueTree.toString(); - } - return String.valueOf(defaultValue); - } - private String enumConstant(String value) { int index = value.lastIndexOf('.'); return index < 0 ? value : value.substring(index + 1); } - private static boolean isInstance(String className, Object value) { - if (value == null) { - return false; - } - return hasType(value.getClass(), className); - } - - private static boolean hasType(Class type, String className) { - if (type == null) { - return false; - } - if (type.getName().equals(className)) { - return true; - } - for (Class interfaceType : type.getInterfaces()) { - if (hasType(interfaceType, className)) { - return true; - } - } - return hasType(type.getSuperclass(), className); - } - - private static Object invoke(Object target, String methodName) { - return invoke(target, methodName, new Class[0]); - } - - private static Object invoke( - Object target, String methodName, Class[] parameterTypes, Object... args) { - if (target == null) { - return null; - } - try { - Method method = target.getClass().getMethod(methodName, parameterTypes); - return method.invoke(target, args); - } catch (ReflectiveOperationException e) { - return null; - } - } - - private static List listValue(Object value) { - if (value instanceof List) { - return (List) value; - } - return Collections.emptyList(); - } - - private static final class TypeTreeInfo { - final List annotations; - final Object tree; - - TypeTreeInfo(List annotations, Object tree) { - this.annotations = annotations; - this.tree = tree; - } - - Object arrayComponentTree() { - if (isInstance("com.sun.source.tree.ArrayTypeTree", tree)) { - return invoke(tree, "getType"); - } - return null; - } - - List typeArgumentTrees() { - if (isInstance("com.sun.source.tree.ParameterizedTypeTree", tree)) { - return listValue(invoke(tree, "getTypeArguments")); - } - return Collections.emptyList(); - } - } - private static final class TypeUseAnnotation { final AnnotationMirror mirror; final Object tree; diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java new file mode 100644 index 0000000000..3e1ed0ca9e --- /dev/null +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.annotation.processing; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +/** Java 8-compatible access to javac type-use trees omitted from some {@code TypeMirror}s. */ +final class JavacTypeUseTrees { + private final Object trees; + + JavacTypeUseTrees(ProcessingEnvironment processingEnv) { + Object javacTrees; + try { + ClassLoader javacLoader = processingEnv.getClass().getClassLoader(); + Class treesClass = + Class.forName( + "com.sun.source.util.Trees", + false, + javacLoader == null ? ClassLoader.getSystemClassLoader() : javacLoader); + javacTrees = + treesClass.getMethod("instance", ProcessingEnvironment.class).invoke(null, processingEnv); + } catch (ReflectiveOperationException | IllegalArgumentException e) { + javacTrees = null; + } + trees = javacTrees; + } + + Object typeTree(Element element) { + if (trees == null) { + return null; + } + Object path = invoke(trees, "getPath", new Class[] {Element.class}, element); + if (path == null) { + return null; + } + Object leaf = invoke(path, "getLeaf"); + if (isInstance("com.sun.source.tree.VariableTree", leaf)) { + return invoke(leaf, "getType"); + } + if (isInstance("com.sun.source.tree.MethodTree", leaf)) { + return invoke(leaf, "getReturnType"); + } + return null; + } + + Tree tree(Object tree) { + List annotations = Collections.emptyList(); + Object current = tree; + while (isInstance("com.sun.source.tree.AnnotatedTypeTree", current)) { + annotations = listValue(invoke(current, "getAnnotations")); + current = invoke(current, "getUnderlyingType"); + } + return new Tree(annotations, current); + } + + boolean isAnnotation(Object annotationTree, String annotationName) { + Object annotationType = invoke(annotationTree, "getAnnotationType"); + if (annotationType == null) { + return false; + } + String treeName = annotationType.toString(); + int separator = annotationName.lastIndexOf('.'); + String simpleName = separator < 0 ? annotationName : annotationName.substring(separator + 1); + return treeName.equals(annotationName) || treeName.equals(simpleName); + } + + String annotationValue(Object annotationTree, String name, Object defaultValue) { + for (Object argument : listValue(invoke(annotationTree, "getArguments"))) { + Object valueTree = argument; + if (isInstance("com.sun.source.tree.AssignmentTree", argument)) { + Object variable = invoke(argument, "getVariable"); + if (variable == null || !variable.toString().equals(name)) { + continue; + } + valueTree = invoke(argument, "getExpression"); + } + return valueTree.toString(); + } + return String.valueOf(defaultValue); + } + + TypeMirror annotationClassValue(Element owner, Object annotationTree, String name) { + Object valueTree = null; + for (Object argument : listValue(invoke(annotationTree, "getArguments"))) { + Object candidate = argument; + if (isInstance("com.sun.source.tree.AssignmentTree", argument)) { + Object variable = invoke(argument, "getVariable"); + if (variable == null || !variable.toString().equals(name)) { + continue; + } + candidate = invoke(argument, "getExpression"); + } + valueTree = candidate; + break; + } + if (valueTree == null || trees == null) { + return null; + } + if (isInstance("com.sun.source.tree.MemberSelectTree", valueTree) + && "class".equals(String.valueOf(invoke(valueTree, "getIdentifier")))) { + valueTree = invoke(valueTree, "getExpression"); + } + Object ownerPath = invoke(trees, "getPath", new Class[] {Element.class}, owner); + Object compilationUnit = invoke(ownerPath, "getCompilationUnit"); + if (compilationUnit == null) { + return null; + } + try { + Class compilationUnitClass = + Class.forName( + "com.sun.source.tree.CompilationUnitTree", false, trees.getClass().getClassLoader()); + Class treeClass = + Class.forName("com.sun.source.tree.Tree", false, trees.getClass().getClassLoader()); + Class treePathClass = + Class.forName("com.sun.source.util.TreePath", false, trees.getClass().getClassLoader()); + Object valuePath = + treePathClass + .getMethod("getPath", compilationUnitClass, treeClass) + .invoke(null, compilationUnit, valueTree); + Object type = invoke(trees, "getTypeMirror", new Class[] {treePathClass}, valuePath); + return type instanceof TypeMirror ? (TypeMirror) type : null; + } catch (ReflectiveOperationException | LinkageError e) { + return null; + } + } + + static final class Tree { + final List annotations; + private final Object tree; + + Tree(List annotations, Object tree) { + this.annotations = annotations; + this.tree = tree; + } + + Object arrayComponentTree() { + if (isInstance("com.sun.source.tree.ArrayTypeTree", tree)) { + return invoke(tree, "getType"); + } + return null; + } + + List typeArgumentTrees() { + if (isInstance("com.sun.source.tree.ParameterizedTypeTree", tree)) { + return listValue(invoke(tree, "getTypeArguments")); + } + return Collections.emptyList(); + } + + Object wildcardBoundTree() { + if (isInstance("com.sun.source.tree.WildcardTree", tree)) { + return invoke(tree, "getBound"); + } + return null; + } + } + + private static boolean isInstance(String className, Object value) { + return value != null && hasType(value.getClass(), className); + } + + private static boolean hasType(Class type, String className) { + if (type == null) { + return false; + } + if (type.getName().equals(className)) { + return true; + } + for (Class interfaceType : type.getInterfaces()) { + if (hasType(interfaceType, className)) { + return true; + } + } + return hasType(type.getSuperclass(), className); + } + + private static Object invoke(Object target, String methodName) { + return invoke(target, methodName, new Class[0]); + } + + private static Object invoke( + Object target, String methodName, Class[] parameterTypes, Object... args) { + if (target == null) { + return null; + } + try { + Method method = target.getClass().getMethod(methodName, parameterTypes); + return method.invoke(target, args); + } catch (ReflectiveOperationException e) { + return null; + } + } + + private static List listValue(Object value) { + return value instanceof List ? (List) value : Collections.emptyList(); + } +} diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java new file mode 100644 index 0000000000..7c76450806 --- /dev/null +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java @@ -0,0 +1,1469 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.annotation.processing; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.ArrayType; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.WildcardType; +import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; + +/** Generates Android codec type-use metadata and precise R8 rules for {@code JsonType} models. */ +final class JsonTypeProcessor { + private static final String JSON_PACKAGE = "org.apache.fory.json"; + private static final String JSON_TYPE = JSON_PACKAGE + ".annotation.JsonType"; + private static final String JSON_CODEC = JSON_PACKAGE + ".annotation.JsonCodec"; + private static final String JSON_SUB_TYPES = JSON_PACKAGE + ".annotation.JsonSubTypes"; + private static final String JSON_CREATOR = JSON_PACKAGE + ".annotation.JsonCreator"; + private static final String JSON_PROPERTY = JSON_PACKAGE + ".annotation.JsonProperty"; + private static final String JSON_ANY_PROPERTY = JSON_PACKAGE + ".annotation.JsonAnyProperty"; + private static final String JSON_ANY_GETTER = JSON_PACKAGE + ".annotation.JsonAnyGetter"; + private static final String JSON_ANY_SETTER = JSON_PACKAGE + ".annotation.JsonAnySetter"; + private static final String GENERATED_META = JSON_PACKAGE + ".meta.GeneratedJsonCodecMeta"; + private static final String JSON_TYPE_USE = JSON_PACKAGE + ".meta.JsonTypeUse"; + private static final String JSON_VALUE_CODEC = JSON_PACKAGE + ".codec.JsonValueCodec"; + private static final String META_SUFFIX = "_ForyJsonCodecMeta"; + private static final String R8_PREFIX = "META-INF/com.android.tools/r8/fory-json-"; + + private final Filer filer; + private final Messager messager; + private final Elements elements; + private final Types types; + private final JavacTypeUseTrees typeUseTrees; + private final Set processedTypes = new HashSet<>(); + + JsonTypeProcessor(ProcessingEnvironment environment, JavacTypeUseTrees typeUseTrees) { + filer = environment.getFiler(); + messager = environment.getMessager(); + elements = environment.getElementUtils(); + types = environment.getTypeUtils(); + this.typeUseTrees = typeUseTrees; + } + + void process(RoundEnvironment roundEnvironment) { + TypeElement jsonType = elements.getTypeElement(JSON_TYPE); + if (jsonType == null) { + return; + } + Deque pending = new ArrayDeque<>(); + for (Element element : roundEnvironment.getElementsAnnotatedWith(jsonType)) { + if (element instanceof TypeElement) { + pending.add((TypeElement) element); + } + } + while (!pending.isEmpty()) { + TypeElement type = pending.removeFirst(); + if (type.getKind().name().equals("RECORD")) { + continue; + } + String binaryName = elements.getBinaryName(type).toString(); + if (!processedTypes.add(binaryName)) { + continue; + } + try { + Model model = inspect(type); + List subtypes = classLiteralSubtypes(type, model.binaryFallbackTypes); + model.sort(); + if (!model.generatedMembers.isEmpty()) { + emitMeta(model); + } + emitR8(model); + pending.addAll(subtypes); + } catch (InvalidJsonTypeException e) { + messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), e.element); + } catch (RuntimeException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to generate Fory JSON metadata for " + binaryName + ": " + e.getMessage(), + type); + } + } + } + + private Model inspect(TypeElement target) { + PackageElement packageElement = elements.getPackageOf(target); + String packageName = + packageElement.isUnnamed() ? "" : packageElement.getQualifiedName().toString(); + String binaryName = elements.getBinaryName(target).toString(); + String binarySimpleName = + binaryName.substring(packageName.isEmpty() ? 0 : packageName.length() + 1); + Model model = new Model(target, packageName, binaryName, binarySimpleName + META_SUFFIX); + collectAnnotations(target, model.annotationTypes); + collectCodecAnnotation(annotationMirror(target, JSON_CODEC), model); + model.annotationOwnerTypes.add(binaryName); + + List classes = classHierarchy(target); + Collections.reverse(classes); + for (TypeElement type : classes) { + collectAnnotations(type, model.annotationTypes); + collectCodecAnnotation(annotationMirror(type, JSON_CODEC), model); + if (hasJsonAnnotations(type)) { + model.annotationOwnerTypes.add(elements.getBinaryName(type).toString()); + } + for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { + collectAnnotations(field, model.annotationTypes); + collectCodecAnnotation(annotationMirror(field, JSON_CODEC), model); + if (field.getKind() == ElementKind.ENUM_CONSTANT) { + model.addR8Member(R8Member.field(field, typeName(field.asType()))); + continue; + } + if (isEligibleField(field)) { + model.addR8Member(R8Member.field(field, typeName(field.asType()))); + collectTypeEndpoints(field.asType(), model); + Member generated = generatedField(field, packageName, model); + if (generated != null) { + model.generatedMembers.add(generated); + } + } else if (hasAnnotation(field, JSON_PROPERTY) + || hasAnnotation(field, JSON_ANY_PROPERTY) + || hasAnnotation(field, JSON_CODEC)) { + throw new InvalidJsonTypeException( + "JSON annotations are not supported on ineligible field " + field, field); + } + } + } + + for (TypeElement owner : allMethodOwners(target)) { + collectAnnotations(owner, model.annotationTypes); + collectCodecAnnotation(annotationMirror(owner, JSON_CODEC), model); + if (hasJsonAnnotations(owner)) { + model.annotationOwnerTypes.add(elements.getBinaryName(owner).toString()); + } + } + // Generated metadata and ordinary property rules follow Java's effective method set. An + // unannotated override suppresses the inherited declaration, while creators remain exact to + // the target declaration below. + List jsonMethods = jsonMethods(target); + for (ExecutableElement method : jsonMethods) { + TypeElement owner = (TypeElement) method.getEnclosingElement(); + validateMethodCodec(method); + model.addR8Member( + R8Member.method(method, typeName(method.getReturnType()), typeNames(method))); + collectAnnotations(method, model.annotationTypes); + collectCodecAnnotation(annotationMirror(method, JSON_CODEC), model); + collectAnnotations(method.getParameters(), model.annotationTypes); + collectTypeEndpoints(method.getReturnType(), model); + for (VariableElement parameter : method.getParameters()) { + collectTypeEndpoints(parameter.asType(), model); + } + Member generated = generatedExecutable(method, packageName, model); + if (generated != null) { + model.generatedMembers.add(generated); + } + collectAnnotations(owner, model.annotationTypes); + } + collectValidationMethods(target, jsonMethods, model); + + for (ExecutableElement constructor : + ElementFilter.constructorsIn(target.getEnclosedElements())) { + collectAnnotations(constructor, model.annotationTypes); + collectAnnotations(constructor.getParameters(), model.annotationTypes); + boolean creator = hasAnnotation(constructor, JSON_CREATOR); + if (creator || isNoArg(constructor)) { + model.addR8Member(R8Member.constructor(constructor, typeNames(constructor))); + } + if (creator) { + for (VariableElement parameter : constructor.getParameters()) { + collectTypeEndpoints(parameter.asType(), model); + } + Member generated = generatedExecutable(constructor, packageName, model); + if (generated != null) { + model.generatedMembers.add(generated); + } + } + } + return model; + } + + private Member generatedField(VariableElement field, String packageName, Model model) { + List codecs = new ArrayList<>(); + collectCodecs( + field.asType(), + typeUseTrees.typeTree(field), + field, + packageName, + model, + !hasAnnotation(field, JSON_CODEC), + new ArrayList(), + codecs); + if (codecs.isEmpty()) { + return null; + } + TypeElement owner = (TypeElement) field.getEnclosingElement(); + return Member.field( + owner, + field.getSimpleName().toString(), + reflectionType(owner.asType(), packageName, model), + codecs); + } + + private Member generatedExecutable( + ExecutableElement executable, String packageName, Model model) { + List> slots = new ArrayList<>(); + if (executable.getKind() == ElementKind.METHOD) { + List returnCodecs = new ArrayList<>(); + collectCodecs( + executable.getReturnType(), + typeUseTrees.typeTree(executable), + executable, + packageName, + model, + !hasAnnotation(executable, JSON_CODEC), + new ArrayList(), + returnCodecs); + slots.add(returnCodecs); + } else { + slots.add(Collections.emptyList()); + } + for (VariableElement parameter : executable.getParameters()) { + List parameterCodecs = new ArrayList<>(); + collectCodecs( + parameter.asType(), + typeUseTrees.typeTree(parameter), + parameter, + packageName, + model, + true, + new ArrayList(), + parameterCodecs); + slots.add(parameterCodecs); + } + boolean hasCodecs = false; + for (List slot : slots) { + hasCodecs |= !slot.isEmpty(); + } + if (!hasCodecs) { + return null; + } + TypeElement owner = (TypeElement) executable.getEnclosingElement(); + List parameterTypes = new ArrayList<>(); + for (VariableElement parameter : executable.getParameters()) { + parameterTypes.add(reflectionType(parameter.asType(), packageName, model)); + } + return Member.executable( + executable.getKind() == ElementKind.CONSTRUCTOR, + owner, + executable.getSimpleName().toString(), + reflectionType(owner.asType(), packageName, model), + parameterTypes, + slots); + } + + private void collectCodecs( + TypeMirror type, + Object tree, + Element source, + String packageName, + Model model, + boolean includeRoot, + List path, + List codecs) { + if (includeRoot) { + collectCodecNode(type, tree, source, packageName, model, path, codecs); + } else { + collectNestedCodecs(type, tree, source, packageName, model, path, codecs); + } + } + + private void collectNestedCodecs( + TypeMirror type, + Object tree, + Element source, + String packageName, + Model model, + List path, + List codecs) { + JavacTypeUseTrees.Tree treeInfo = typeUseTrees.tree(tree); + TypeKind kind = type.getKind(); + if (type instanceof DeclaredType) { + List arguments = ((DeclaredType) type).getTypeArguments(); + List argumentTrees = treeInfo.typeArgumentTrees(); + for (int i = 0; i < arguments.size(); i++) { + List childPath = childPath(path, i); + Object childTree = i < argumentTrees.size() ? argumentTrees.get(i) : null; + collectCodecNode( + arguments.get(i), childTree, source, packageName, model, childPath, codecs); + } + } else if (kind == TypeKind.ARRAY) { + collectCodecNode( + ((ArrayType) type).getComponentType(), + treeInfo.arrayComponentTree(), + source, + packageName, + model, + childPath(path, -1), + codecs); + } else if (kind == TypeKind.WILDCARD) { + WildcardType wildcard = (WildcardType) type; + TypeMirror upper = wildcard.getExtendsBound(); + TypeMirror lower = wildcard.getSuperBound(); + if (upper != null) { + collectCodecNode( + upper, + treeInfo.wildcardBoundTree(), + source, + packageName, + model, + childPath(path, -2), + codecs); + } + if (lower != null) { + collectCodecNode( + lower, + treeInfo.wildcardBoundTree(), + source, + packageName, + model, + childPath(path, -3), + codecs); + } + } + } + + private void collectCodecNode( + TypeMirror type, + Object tree, + Element source, + String packageName, + Model model, + List path, + List codecs) { + TypeElement codec = codecClass(type, tree, source); + if (codec != null) { + codecs.add(codecPath(codec, path, packageName, model)); + } + collectNestedCodecs(type, tree, source, packageName, model, path, codecs); + } + + private TypeElement codecClass(TypeMirror type, Object tree, Element source) { + AnnotationMirror mirror = annotationMirror(type, JSON_CODEC); + if (mirror != null) { + AnnotationValue value = annotationValue(mirror, "value"); + Object rawValue = value == null ? null : value.getValue(); + if (rawValue instanceof TypeMirror) { + Element codec = types.asElement((TypeMirror) rawValue); + if (codec instanceof TypeElement) { + return (TypeElement) codec; + } + } + throw new InvalidJsonTypeException("Cannot resolve @JsonCodec value", source); + } + for (Object annotationTree : typeUseTrees.tree(tree).annotations) { + if (!typeUseTrees.isAnnotation(annotationTree, JSON_CODEC)) { + continue; + } + TypeMirror codecType = typeUseTrees.annotationClassValue(source, annotationTree, "value"); + Element codec = codecType == null ? null : types.asElement(codecType); + if (codec instanceof TypeElement) { + return (TypeElement) codec; + } + throw new InvalidJsonTypeException("Cannot resolve type-use @JsonCodec value", source); + } + return null; + } + + private CodecPath codecPath( + TypeElement codec, List path, String packageName, Model model) { + String binaryName = elements.getBinaryName(codec).toString(); + model.codecTypes.add(binaryName); + model.annotationTypes.add(JSON_CODEC); + String expression; + if (isSourceAccessible(codec, packageName)) { + // R8 rewrites class literals when it obfuscates an accessible codec. A binary-name lookup + // cannot be rewritten, so only that reflection fallback requires preserving the exact name. + expression = codec.getQualifiedName() + ".class"; + } else { + expression = "codecClass(\"" + escape(binaryName) + "\")"; + model.binaryFallbackTypes.add(binaryName); + model.needsClassLookup = true; + model.needsCodecLookup = true; + } + return new CodecPath(binaryName, expression, path); + } + + private void collectCodecAnnotation(AnnotationMirror annotation, Model model) { + if (annotation == null) { + return; + } + AnnotationValue value = annotationValue(annotation, "value"); + if (value == null || !(value.getValue() instanceof TypeMirror)) { + return; + } + TypeElement codec = asTypeElement((TypeMirror) value.getValue()); + if (codec != null) { + model.codecTypes.add(elements.getBinaryName(codec).toString()); + model.annotationTypes.add(JSON_CODEC); + } + } + + private void emitMeta(Model model) { + String qualifiedName = + model.packageName.isEmpty() + ? model.metaSimpleName + : model.packageName + "." + model.metaSimpleName; + try { + JavaFileObject file = filer.createSourceFile(qualifiedName, model.target); + try (Writer writer = file.openWriter()) { + writer.write(writeMeta(model)); + } + } catch (IOException e) { + throw new InvalidJsonTypeException( + "Failed to write generated JSON metadata: " + e, model.target); + } + } + + private String writeMeta(Model model) { + StringBuilder builder = new StringBuilder(8192); + if (!model.packageName.isEmpty()) { + builder.append("package ").append(model.packageName).append(";\n\n"); + } + if (model.hasGeneratedField()) { + builder.append("import java.lang.reflect.Field;\n"); + } + if (model.hasGeneratedMethod()) { + builder.append("import java.lang.reflect.Method;\n"); + } + if (model.hasGeneratedConstructor()) { + builder.append("import java.lang.reflect.Constructor;\n"); + } + builder.append("import java.lang.reflect.Member;\n"); + builder.append("import java.util.Collections;\n"); + builder.append("import java.util.LinkedHashMap;\n"); + builder.append("import java.util.Map;\n"); + builder.append("import ").append(GENERATED_META).append(";\n"); + builder.append("import ").append(JSON_TYPE_USE).append(";\n"); + if (model.needsCodecLookup) { + builder.append("import ").append(JSON_VALUE_CODEC).append(";\n"); + } + builder.append('\n'); + builder + .append("public final class ") + .append(model.metaSimpleName) + .append(" implements GeneratedJsonCodecMeta {\n"); + builder.append( + " private static final Map TYPE_USES = buildTypeUses();\n\n"); + builder.append(" public ").append(model.metaSimpleName).append("() {}\n\n"); + builder.append(" @Override\n"); + builder.append(" public Map typeUses() {\n"); + builder.append(" return TYPE_USES;\n"); + builder.append(" }\n\n"); + builder.append(" private static Map buildTypeUses() {\n"); + builder.append(" try {\n"); + builder.append(" Map values = new LinkedHashMap<>();\n"); + for (int memberIndex = 0; memberIndex < model.generatedMembers.size(); memberIndex++) { + Member member = model.generatedMembers.get(memberIndex); + String variable = "member" + memberIndex; + builder + .append(" ") + .append(member.memberType()) + .append(' ') + .append(variable) + .append(" = "); + member.appendLookup(builder); + builder.append(";\n"); + builder + .append(" JsonTypeUse[] slots") + .append(memberIndex) + .append(" = new JsonTypeUse[") + .append(member.slots.size()) + .append("];\n"); + for (int slotIndex = 0; slotIndex < member.slots.size(); slotIndex++) { + List codecs = member.slots.get(slotIndex); + for (CodecPath codec : codecs) { + builder + .append(" slots") + .append(memberIndex) + .append('[') + .append(slotIndex) + .append("] = JsonTypeUse.merge(slots") + .append(memberIndex) + .append('[') + .append(slotIndex) + .append("], JsonTypeUse.generated("); + member.appendGenericType(builder, variable, slotIndex); + builder.append(", ").append(codec.expression).append(", \""); + builder.append(escape(member.source(slotIndex))).append("\""); + for (int pathStep : codec.path) { + builder.append(", ").append(pathExpression(pathStep)); + } + builder.append("), \"").append(escape(member.source(slotIndex))).append("\");\n"); + } + } + builder + .append(" values.put(") + .append(variable) + .append(", slots") + .append(memberIndex) + .append(");\n"); + } + builder.append(" return Collections.unmodifiableMap(values);\n"); + builder.append(" } catch (ReflectiveOperationException e) {\n"); + builder.append(" throw new ExceptionInInitializerError(e);\n"); + builder.append(" }\n"); + builder.append(" }\n\n"); + if (model.needsClassLookup) { + builder.append( + " private static Class classForName(String name) throws ClassNotFoundException {\n"); + builder + .append(" return Class.forName(name, false, ") + .append(model.metaSimpleName) + .append(".class.getClassLoader());\n"); + builder.append(" }\n"); + } + if (model.needsCodecLookup) { + builder.append("\n @SuppressWarnings({\"rawtypes\", \"unchecked\"})\n"); + builder + .append(" private static Class> codecClass(String name) ") + .append("throws ClassNotFoundException {\n"); + builder.append(" return (Class) classForName(name);\n"); + builder.append(" }\n"); + } + return builder.append("}\n").toString(); + } + + private void emitR8(Model model) { + String resourceName = R8_PREFIX + model.binaryName + ".pro"; + try { + javax.tools.FileObject file = + filer.createResource(StandardLocation.CLASS_OUTPUT, "", resourceName, model.target); + try (Writer writer = file.openWriter()) { + writer.write(writeR8(model)); + } + } catch (IOException e) { + throw new InvalidJsonTypeException("Failed to write Fory JSON R8 rules: " + e, model.target); + } + } + + private String writeR8(Model model) { + StringBuilder builder = new StringBuilder(8192); + builder.append( + "-keepattributes Signature,RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations\n"); + builder.append( + "-keepattributes RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations\n"); + builder.append( + "-keepattributes RuntimeVisibleTypeAnnotations,RuntimeInvisibleTypeAnnotations,AnnotationDefault\n"); + builder.append("-keepattributes MethodParameters\n"); + if (model.hasNestedIdentity()) { + builder.append("-keepattributes InnerClasses,EnclosingMethod\n"); + } + builder.append('\n'); + Map> membersByOwner = new LinkedHashMap<>(); + membersByOwner.put(model.binaryName, new ArrayList()); + for (String annotationOwner : model.annotationOwnerTypes) { + membersByOwner.putIfAbsent(annotationOwner, new ArrayList()); + } + for (R8Member member : model.r8Members) { + membersByOwner + .computeIfAbsent(member.ownerBinaryName, key -> new ArrayList()) + .add(member); + } + for (Map.Entry> entry : membersByOwner.entrySet()) { + boolean preserveName = + entry.getKey().equals(model.binaryName) && !model.generatedMembers.isEmpty() + || model.binaryFallbackTypes.contains(entry.getKey()); + builder + .append("-keep,allowoptimization") + .append(preserveName ? "" : ",allowobfuscation") + .append(" class ") + .append(entry.getKey()) + .append("\n"); + if (entry.getValue().isEmpty()) { + builder.append('\n'); + continue; + } + builder + .append("-keepclassmembers,allowoptimization class ") + .append(entry.getKey()) + .append(" {\n"); + for (R8Member member : entry.getValue()) { + builder.append(" ").append(member.declaration).append("\n"); + } + builder.append("}\n\n"); + } + for (String fallback : model.binaryFallbackTypes) { + if (!membersByOwner.containsKey(fallback)) { + builder.append("-keep,allowoptimization class ").append(fallback); + if (model.codecTypes.contains(fallback)) { + builder.append(" { public (); }"); + } + builder.append("\n"); + } + } + for (String annotationType : model.annotationTypes) { + builder + .append("-keep,allowoptimization,allowobfuscation @interface ") + .append(annotationType) + .append("\n"); + } + for (String container : model.containerTypes) { + builder + .append("-keep,allowoptimization,allowobfuscation class ") + .append(container) + .append(" { public (); }\n"); + } + for (String codec : model.codecTypes) { + if (model.binaryFallbackTypes.contains(codec)) { + continue; + } + builder + .append("-keep,allowoptimization,allowobfuscation class ") + .append(codec) + .append(" { public (); }\n"); + } + if (!model.generatedMembers.isEmpty()) { + builder + .append("-keep,allowoptimization class ") + .append(model.qualifiedMetaName()) + .append(" implements ") + .append(GENERATED_META) + .append(" {\n") + .append(" public ();\n") + .append(" public java.util.Map typeUses();\n") + .append("}\n"); + } + return builder.toString(); + } + + private List classLiteralSubtypes( + TypeElement target, Set binaryFallbackTypes) { + AnnotationMirror subtypes = annotationMirror(target, JSON_SUB_TYPES); + if (subtypes == null) { + return Collections.emptyList(); + } + AnnotationValue entriesValue = annotationValue(subtypes, "value"); + if (entriesValue == null || !(entriesValue.getValue() instanceof List)) { + return Collections.emptyList(); + } + List subtypesToProcess = new ArrayList<>(); + for (Object rawEntry : (List) entriesValue.getValue()) { + Object entryValue = + rawEntry instanceof AnnotationValue ? ((AnnotationValue) rawEntry).getValue() : null; + if (!(entryValue instanceof AnnotationMirror)) { + continue; + } + AnnotationMirror entry = (AnnotationMirror) entryValue; + AnnotationValue classValue = annotationValue(entry, "value"); + if (classValue != null && classValue.getValue() instanceof TypeMirror) { + TypeMirror subtypeMirror = (TypeMirror) classValue.getValue(); + TypeElement subtype = asTypeElement(subtypeMirror); + if (subtype != null && !subtype.getQualifiedName().contentEquals("java.lang.Void")) { + subtypesToProcess.add(subtype); + } + } + AnnotationValue classNameValue = annotationValue(entry, "className"); + if (classNameValue != null) { + String className = String.valueOf(classNameValue.getValue()); + if (!className.isEmpty()) { + binaryFallbackTypes.add(className); + } + } + } + subtypesToProcess.sort(Comparator.comparing(type -> elements.getBinaryName(type).toString())); + return subtypesToProcess; + } + + private List classHierarchy(TypeElement target) { + List hierarchy = new ArrayList<>(); + TypeElement current = target; + while (current != null && !current.getQualifiedName().contentEquals("java.lang.Object")) { + hierarchy.add(current); + TypeMirror superclass = current.getSuperclass(); + current = superclass.getKind() == TypeKind.NONE ? null : asTypeElement(superclass); + } + return hierarchy; + } + + private List jsonMethods(TypeElement target) { + Map methods = new LinkedHashMap<>(); + for (ExecutableElement method : ElementFilter.methodsIn(elements.getAllMembers(target))) { + TypeElement owner = (TypeElement) method.getEnclosingElement(); + if (!usesJsonMetadata(method, owner.equals(target))) { + continue; + } + String key = elements.getBinaryName(owner) + "#" + methodSignature(method); + methods.put(key, method); + } + List result = new ArrayList<>(methods.values()); + result.sort(Comparator.comparing(this::memberSortKey)); + return result; + } + + private void collectValidationMethods( + TypeElement target, List effectiveMethods, Model model) { + // Runtime validation scans declared class methods, including private and static declarations + // that property discovery cannot select. R8 must retain those declarations and annotations so + // release builds reject the same invalid model instead of silently losing the annotation. + Set effectiveKeys = new HashSet<>(); + for (ExecutableElement method : effectiveMethods) { + effectiveKeys.add(methodKey(method)); + } + for (TypeElement owner : classHierarchy(target)) { + boolean targetDeclaration = owner.equals(target); + for (ExecutableElement method : ElementFilter.methodsIn(owner.getEnclosedElements())) { + if (!hasMethodValidationAnnotation(method) + || !isEffectiveValidationMethod(method, targetDeclaration, effectiveKeys)) { + continue; + } + validateMethodCodec(method); + collectAnnotations(method, model.annotationTypes); + collectAnnotations(method.getParameters(), model.annotationTypes); + model.addR8Member( + R8Member.method(method, typeName(method.getReturnType()), typeNames(method))); + } + } + } + + private boolean isEffectiveValidationMethod( + ExecutableElement method, boolean targetDeclaration, Set effectiveKeys) { + Set modifiers = method.getModifiers(); + return targetDeclaration + || !modifiers.contains(Modifier.PUBLIC) + || modifiers.contains(Modifier.STATIC) + || effectiveKeys.contains(methodKey(method)); + } + + private boolean hasMethodValidationAnnotation(ExecutableElement method) { + return hasAnnotation(method, JSON_CODEC) + || hasAnnotation(method, JSON_PROPERTY) + || hasAnnotation(method, JSON_ANY_GETTER) + || hasAnnotation(method, JSON_ANY_SETTER); + } + + private String methodKey(ExecutableElement method) { + TypeElement owner = (TypeElement) method.getEnclosingElement(); + return elements.getBinaryName(owner) + "#" + methodSignature(method); + } + + private List allMethodOwners(TypeElement target) { + LinkedHashMap owners = new LinkedHashMap<>(); + Deque pending = new ArrayDeque<>(); + pending.add(target); + while (!pending.isEmpty()) { + TypeElement type = pending.removeFirst(); + String binaryName = elements.getBinaryName(type).toString(); + if (owners.put(binaryName, type) != null) { + continue; + } + TypeMirror superclass = type.getSuperclass(); + TypeElement superType = + superclass.getKind() == TypeKind.NONE ? null : asTypeElement(superclass); + if (superType != null && !superType.getQualifiedName().contentEquals("java.lang.Object")) { + pending.add(superType); + } + for (TypeMirror interfaceType : type.getInterfaces()) { + TypeElement interfaceElement = asTypeElement(interfaceType); + if (interfaceElement != null) { + pending.add(interfaceElement); + } + } + } + return new ArrayList<>(owners.values()); + } + + private boolean usesJsonMetadata(ExecutableElement method, boolean targetDeclaration) { + if (hasAnnotation(method, JSON_PROPERTY) + || hasAnnotation(method, JSON_ANY_GETTER) + || hasAnnotation(method, JSON_ANY_SETTER) + || hasAnnotation(method, JSON_CODEC)) { + return true; + } + if (targetDeclaration && hasAnnotation(method, JSON_CREATOR)) { + return true; + } + Set modifiers = method.getModifiers(); + if (!modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.STATIC)) { + return false; + } + String name = method.getSimpleName().toString(); + int parameters = method.getParameters().size(); + String returnType = typeName(method.getReturnType()); + return parameters == 0 + && method.getReturnType().getKind() != TypeKind.VOID + && !returnType.equals("java.lang.Class") + && (name.startsWith("get") && name.length() > 3 + || name.startsWith("is") + && name.length() > 2 + && (method.getReturnType().getKind() == TypeKind.BOOLEAN + || returnType.equals("java.lang.Boolean"))) + || parameters == 1 + && method.getReturnType().getKind() == TypeKind.VOID + && !typeName(method.getParameters().get(0).asType()).equals("java.lang.Class") + && name.startsWith("set") + && name.length() > 3; + } + + private void validateMethodCodec(ExecutableElement method) { + if (!hasAnnotation(method, JSON_CODEC)) { + return; + } + Set modifiers = method.getModifiers(); + String name = method.getSimpleName().toString(); + String returnType = typeName(method.getReturnType()); + boolean getterName = + name.startsWith("get") && name.length() > 3 + || name.startsWith("is") + && name.length() > 2 + && (method.getReturnType().getKind() == TypeKind.BOOLEAN + || returnType.equals("java.lang.Boolean")); + if (!modifiers.contains(Modifier.PUBLIC) + || modifiers.contains(Modifier.STATIC) + || !method.getParameters().isEmpty() + || method.getReturnType().getKind() == TypeKind.VOID + || returnType.equals("java.lang.Class") + || !getterName + || hasAnnotation(method, JSON_ANY_GETTER) + || hasAnnotation(method, JSON_ANY_SETTER)) { + throw new InvalidJsonTypeException( + "@JsonCodec is not supported on JSON method " + method, method); + } + } + + private boolean isEligibleField(VariableElement field) { + Set modifiers = field.getModifiers(); + return !modifiers.contains(Modifier.STATIC) + && !modifiers.contains(Modifier.TRANSIENT) + && !typeName(field.asType()).equals("java.lang.Class"); + } + + private boolean isNoArg(ExecutableElement constructor) { + return constructor.getParameters().isEmpty(); + } + + private void collectTypeEndpoints(TypeMirror type, Model model) { + TypeMirror erased = types.erasure(type); + TypeElement rawType = asTypeElement(erased); + if (rawType != null && isConcreteContainer(rawType)) { + model.containerTypes.add(elements.getBinaryName(rawType).toString()); + } + AnnotationMirror codec = annotationMirror(type, JSON_CODEC); + collectCodecAnnotation(codec, model); + if (type instanceof DeclaredType) { + for (TypeMirror argument : ((DeclaredType) type).getTypeArguments()) { + collectTypeEndpoints(argument, model); + } + } else if (type.getKind() == TypeKind.ARRAY) { + collectTypeEndpoints(((ArrayType) type).getComponentType(), model); + } else if (type.getKind() == TypeKind.WILDCARD) { + WildcardType wildcard = (WildcardType) type; + if (wildcard.getExtendsBound() != null) { + collectTypeEndpoints(wildcard.getExtendsBound(), model); + } + if (wildcard.getSuperBound() != null) { + collectTypeEndpoints(wildcard.getSuperBound(), model); + } + } + } + + private boolean isConcreteContainer(TypeElement type) { + if (type.getModifiers().contains(Modifier.ABSTRACT) + || type.getKind() == ElementKind.INTERFACE) { + return false; + } + TypeElement collection = elements.getTypeElement("java.util.Collection"); + TypeElement map = elements.getTypeElement("java.util.Map"); + return collection != null + && types.isAssignable(types.erasure(type.asType()), types.erasure(collection.asType())) + || map != null + && types.isAssignable(types.erasure(type.asType()), types.erasure(map.asType())); + } + + private ReflectionType reflectionType(TypeMirror type, String packageName, Model model) { + TypeMirror erased = types.erasure(type); + if (erased.getKind().isPrimitive()) { + return new ReflectionType(typeName(erased) + ".class", typeName(erased)); + } + if (erased.getKind() == TypeKind.ARRAY) { + if (isSourceAccessible(erased, packageName)) { + return new ReflectionType(sourceType(erased) + ".class", typeName(erased)); + } + String binaryDescriptor = binaryDescriptor(erased); + model.binaryFallbackTypes.add(binaryDescriptorOwner(erased)); + model.needsClassLookup = true; + return new ReflectionType( + "classForName(\"" + escape(binaryDescriptor) + "\")", typeName(erased)); + } + TypeElement element = asTypeElement(erased); + if (element != null && isSourceAccessible(element, packageName)) { + return new ReflectionType(element.getQualifiedName() + ".class", typeName(erased)); + } + String binaryName = + element == null ? typeName(erased) : elements.getBinaryName(element).toString(); + model.binaryFallbackTypes.add(binaryName); + model.needsClassLookup = true; + return new ReflectionType("classForName(\"" + escape(binaryName) + "\")", typeName(erased)); + } + + private boolean isSourceAccessible(TypeMirror type, String packageName) { + if (type.getKind().isPrimitive()) { + return true; + } + if (type.getKind() == TypeKind.ARRAY) { + return isSourceAccessible(((ArrayType) type).getComponentType(), packageName); + } + TypeElement element = asTypeElement(types.erasure(type)); + return element != null && isSourceAccessible(element, packageName); + } + + private boolean isSourceAccessible(TypeElement type, String packageName) { + boolean samePackage = elements.getPackageOf(type).getQualifiedName().contentEquals(packageName); + Element current = type; + while (current instanceof TypeElement) { + Set modifiers = current.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE) + || !samePackage && !modifiers.contains(Modifier.PUBLIC)) { + return false; + } + current = current.getEnclosingElement(); + } + return true; + } + + private String sourceType(TypeMirror type) { + if (type.getKind() == TypeKind.ARRAY) { + return sourceType(((ArrayType) type).getComponentType()) + "[]"; + } + if (type.getKind().isPrimitive()) { + return typeName(type); + } + TypeElement element = asTypeElement(types.erasure(type)); + return element == null ? type.toString() : element.getQualifiedName().toString(); + } + + private String binaryDescriptor(TypeMirror arrayType) { + StringBuilder descriptor = new StringBuilder(); + TypeMirror component = arrayType; + while (component.getKind() == TypeKind.ARRAY) { + descriptor.append('['); + component = ((ArrayType) component).getComponentType(); + } + if (component.getKind().isPrimitive()) { + descriptor.append(primitiveDescriptor(component.getKind())); + } else { + TypeElement element = asTypeElement(types.erasure(component)); + descriptor.append('L').append(elements.getBinaryName(element)).append(';'); + } + return descriptor.toString(); + } + + private String binaryDescriptorOwner(TypeMirror arrayType) { + TypeMirror component = arrayType; + while (component.getKind() == TypeKind.ARRAY) { + component = ((ArrayType) component).getComponentType(); + } + TypeElement element = asTypeElement(types.erasure(component)); + return element == null ? typeName(component) : elements.getBinaryName(element).toString(); + } + + private char primitiveDescriptor(TypeKind kind) { + switch (kind) { + case BOOLEAN: + return 'Z'; + case BYTE: + return 'B'; + case SHORT: + return 'S'; + case INT: + return 'I'; + case LONG: + return 'J'; + case CHAR: + return 'C'; + case FLOAT: + return 'F'; + case DOUBLE: + return 'D'; + default: + throw new IllegalArgumentException("Not a primitive type: " + kind); + } + } + + private List typeNames(ExecutableElement executable) { + List names = new ArrayList<>(); + for (VariableElement parameter : executable.getParameters()) { + names.add(typeName(parameter.asType())); + } + return names; + } + + private String typeName(TypeMirror type) { + TypeMirror erased = types.erasure(type); + if (erased.getKind() == TypeKind.ARRAY) { + return typeName(((ArrayType) erased).getComponentType()) + "[]"; + } + if (erased.getKind().isPrimitive() || erased.getKind() == TypeKind.VOID) { + return erased.toString(); + } + TypeElement element = asTypeElement(erased); + return element == null ? erased.toString() : elements.getBinaryName(element).toString(); + } + + private TypeElement asTypeElement(TypeMirror type) { + Element element = types.asElement(type); + return element instanceof TypeElement ? (TypeElement) element : null; + } + + private void collectAnnotations(Element element, Set annotationTypes) { + for (AnnotationMirror annotation : element.getAnnotationMirrors()) { + Element annotationType = annotation.getAnnotationType().asElement(); + if (annotationType instanceof TypeElement) { + String name = ((TypeElement) annotationType).getQualifiedName().toString(); + if (name.startsWith(JSON_PACKAGE + ".annotation.")) { + annotationTypes.add(name); + } + } + } + } + + private void collectAnnotations( + List sourceElements, Set annotationTypes) { + for (Element element : sourceElements) { + collectAnnotations(element, annotationTypes); + } + } + + private AnnotationMirror annotationMirror(Element element, String annotationName) { + for (AnnotationMirror mirror : element.getAnnotationMirrors()) { + Element annotationType = mirror.getAnnotationType().asElement(); + if (annotationType instanceof TypeElement + && ((TypeElement) annotationType).getQualifiedName().contentEquals(annotationName)) { + return mirror; + } + } + return null; + } + + private AnnotationMirror annotationMirror(TypeMirror type, String annotationName) { + for (AnnotationMirror mirror : type.getAnnotationMirrors()) { + Element annotationType = mirror.getAnnotationType().asElement(); + if (annotationType instanceof TypeElement + && ((TypeElement) annotationType).getQualifiedName().contentEquals(annotationName)) { + return mirror; + } + } + return null; + } + + private boolean hasAnnotation(Element element, String annotationName) { + return annotationMirror(element, annotationName) != null; + } + + private boolean hasJsonAnnotations(Element element) { + for (AnnotationMirror annotation : element.getAnnotationMirrors()) { + Element annotationType = annotation.getAnnotationType().asElement(); + if (annotationType instanceof TypeElement + && ((TypeElement) annotationType) + .getQualifiedName() + .toString() + .startsWith(JSON_PACKAGE + ".annotation.")) { + return true; + } + } + return false; + } + + private AnnotationValue annotationValue(AnnotationMirror annotation, String name) { + for (Map.Entry entry : + elements.getElementValuesWithDefaults(annotation).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals(name)) { + return entry.getValue(); + } + } + return null; + } + + private String memberSortKey(ExecutableElement method) { + return elements.getBinaryName((TypeElement) method.getEnclosingElement()) + + "#" + + methodSignature(method); + } + + private String methodSignature(ExecutableElement method) { + return method.getSimpleName() + "(" + String.join(",", typeNames(method)) + ")"; + } + + private static List childPath(List parent, int step) { + List path = new ArrayList<>(parent.size() + 1); + path.addAll(parent); + path.add(step); + return path; + } + + private static String pathExpression(int step) { + if (step >= 0) { + return Integer.toString(step); + } + switch (step) { + case -1: + return "JsonTypeUse.ARRAY_COMPONENT"; + case -2: + return "JsonTypeUse.WILDCARD_UPPER_BOUND"; + case -3: + return "JsonTypeUse.WILDCARD_LOWER_BOUND"; + default: + throw new IllegalArgumentException("Invalid generated type-use path step " + step); + } + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static final class Model { + final TypeElement target; + final String packageName; + final String binaryName; + final String metaSimpleName; + final List generatedMembers = new ArrayList<>(); + final List r8Members = new ArrayList<>(); + final Set r8MemberKeys = new HashSet<>(); + final Set annotationTypes = new LinkedHashSet<>(); + final Set containerTypes = new LinkedHashSet<>(); + final Set codecTypes = new LinkedHashSet<>(); + final Set binaryFallbackTypes = new LinkedHashSet<>(); + final Set annotationOwnerTypes = new LinkedHashSet<>(); + boolean needsClassLookup; + boolean needsCodecLookup; + + Model(TypeElement target, String packageName, String binaryName, String metaSimpleName) { + this.target = target; + this.packageName = packageName; + this.binaryName = binaryName; + this.metaSimpleName = metaSimpleName; + } + + String qualifiedMetaName() { + return packageName.isEmpty() ? metaSimpleName : packageName + "." + metaSimpleName; + } + + void addR8Member(R8Member member) { + if (r8MemberKeys.add(member.ownerBinaryName + "#" + member.declaration)) { + r8Members.add(member); + } + } + + boolean hasGeneratedField() { + for (Member member : generatedMembers) { + if (member.field) { + return true; + } + } + return false; + } + + boolean hasGeneratedMethod() { + for (Member member : generatedMembers) { + if (!member.field && !member.constructor) { + return true; + } + } + return false; + } + + boolean hasGeneratedConstructor() { + for (Member member : generatedMembers) { + if (member.constructor) { + return true; + } + } + return false; + } + + boolean hasNestedIdentity() { + return binaryName.indexOf('$') >= 0 + || qualifiedMetaName().indexOf('$') >= 0 + || containsNested(binaryFallbackTypes) + || containsNested(annotationOwnerTypes) + || containsNested(containerTypes) + || containsNested(codecTypes) + || containsNestedMember(r8Members); + } + + void sort() { + generatedMembers.sort(Comparator.comparing(Member::sortKey)); + Collections.sort(r8Members); + sortSet(annotationTypes); + sortSet(containerTypes); + sortSet(codecTypes); + sortSet(binaryFallbackTypes); + sortSet(annotationOwnerTypes); + for (Member member : generatedMembers) { + for (List slot : member.slots) { + slot.sort(Comparator.comparing(CodecPath::sortKey)); + } + } + } + + private static boolean containsNested(Set names) { + for (String name : names) { + if (name.indexOf('$') >= 0) { + return true; + } + } + return false; + } + + private static boolean containsNestedMember(List members) { + for (R8Member member : members) { + if (member.ownerBinaryName.indexOf('$') >= 0 || member.declaration.indexOf('$') >= 0) { + return true; + } + } + return false; + } + + private static void sortSet(Set values) { + List sorted = new ArrayList<>(values); + Collections.sort(sorted); + values.clear(); + values.addAll(sorted); + } + } + + private static final class CodecPath { + final String codecBinaryName; + final String expression; + final List path; + + CodecPath(String codecBinaryName, String expression, List path) { + this.codecBinaryName = codecBinaryName; + this.expression = expression; + this.path = new ArrayList<>(path); + } + + String sortKey() { + return path.toString() + "#" + codecBinaryName; + } + + private static String binaryName(TypeElement type) { + Deque names = new ArrayDeque<>(); + Element current = type; + while (current instanceof TypeElement) { + names.addFirst(current.getSimpleName().toString()); + current = current.getEnclosingElement(); + } + String packageName = + current instanceof PackageElement + ? ((PackageElement) current).getQualifiedName().toString() + : ""; + return (packageName.isEmpty() ? "" : packageName + ".") + String.join("$", names); + } + } + + private static final class ReflectionType { + final String expression; + final String r8Name; + + ReflectionType(String expression, String r8Name) { + this.expression = expression; + this.r8Name = r8Name; + } + } + + private static final class Member { + final boolean field; + final boolean constructor; + final String ownerBinaryName; + final String name; + final ReflectionType ownerType; + final List parameterTypes; + final List> slots; + + private Member( + boolean field, + boolean constructor, + String ownerBinaryName, + String name, + ReflectionType ownerType, + List parameterTypes, + List> slots) { + this.field = field; + this.constructor = constructor; + this.ownerBinaryName = ownerBinaryName; + this.name = name; + this.ownerType = ownerType; + this.parameterTypes = parameterTypes; + this.slots = slots; + } + + static Member field( + TypeElement owner, String name, ReflectionType ownerType, List codecs) { + return new Member( + true, + false, + CodecPath.binaryName(owner), + name, + ownerType, + Collections.emptyList(), + Collections.singletonList(codecs)); + } + + static Member executable( + boolean constructor, + TypeElement owner, + String name, + ReflectionType ownerType, + List parameterTypes, + List> slots) { + return new Member( + false, constructor, CodecPath.binaryName(owner), name, ownerType, parameterTypes, slots); + } + + String sortKey() { + StringBuilder key = new StringBuilder(ownerBinaryName).append('#').append(name).append('('); + for (ReflectionType parameterType : parameterTypes) { + key.append(parameterType.r8Name).append(','); + } + return key.append(')').toString(); + } + + String memberType() { + return field ? "Field" : constructor ? "Constructor" : "Method"; + } + + void appendLookup(StringBuilder builder) { + builder.append(ownerType.expression); + if (field) { + builder.append(".getDeclaredField(\"").append(escape(name)).append("\")"); + return; + } + builder + .append(constructor ? ".getDeclaredConstructor(" : ".getDeclaredMethod(\"") + .append(constructor ? "" : escape(name) + "\""); + for (int i = 0; i < parameterTypes.size(); i++) { + if (i > 0 || !constructor) { + builder.append(", "); + } + builder.append(parameterTypes.get(i).expression); + } + builder.append(')'); + } + + void appendGenericType(StringBuilder builder, String variable, int slot) { + if (field) { + builder.append(variable).append(".getGenericType()"); + } else if (slot == 0) { + builder.append(variable).append(".getGenericReturnType()"); + } else { + builder + .append(variable) + .append(".getGenericParameterTypes()[") + .append(slot - 1) + .append(']'); + } + } + + String source(int slot) { + if (field) { + return "field " + ownerBinaryName + "." + name; + } + if (slot == 0 && !constructor) { + return "method return " + ownerBinaryName + "#" + name; + } + return (constructor ? "constructor parameter " : "method parameter ") + + ownerBinaryName + + (constructor ? "" : "#" + name) + + "[" + + (slot - 1) + + "]"; + } + } + + private static final class R8Member implements Comparable { + final String ownerBinaryName; + final String declaration; + + R8Member(String ownerBinaryName, String declaration) { + this.ownerBinaryName = ownerBinaryName; + this.declaration = declaration; + } + + static R8Member field(VariableElement field, String typeName) { + TypeElement owner = (TypeElement) field.getEnclosingElement(); + return new R8Member( + CodecPath.binaryName(owner), typeName + " " + field.getSimpleName() + ";"); + } + + static R8Member method( + ExecutableElement method, String returnType, List parameterTypes) { + TypeElement owner = (TypeElement) method.getEnclosingElement(); + return new R8Member( + CodecPath.binaryName(owner), + returnType + + " " + + method.getSimpleName() + + "(" + + String.join(",", parameterTypes) + + ");"); + } + + static R8Member constructor(ExecutableElement constructor, List parameterTypes) { + TypeElement owner = (TypeElement) constructor.getEnclosingElement(); + return new R8Member( + CodecPath.binaryName(owner), "(" + String.join(",", parameterTypes) + ");"); + } + + @Override + public int compareTo(R8Member other) { + int owner = ownerBinaryName.compareTo(other.ownerBinaryName); + return owner != 0 ? owner : declaration.compareTo(other.declaration); + } + } + + private static final class InvalidJsonTypeException extends RuntimeException { + final Element element; + + InvalidJsonTypeException(String message, Element element) { + super(message); + this.element = element; + } + } +} diff --git a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java new file mode 100644 index 0000000000..4fe2c15e06 --- /dev/null +++ b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java @@ -0,0 +1,439 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.annotation.processing; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Member; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import org.apache.fory.json.meta.GeneratedJsonCodecMeta; +import org.apache.fory.json.meta.JsonTypeUse; +import org.testng.annotations.Test; + +public class JsonTypeProcessorTest { + @Test + public void testUnannotatedType() throws Exception { + CompilationResult result = + compile("test.Plain", "package test; public class Plain { int id; }"); + assertTrue(result.success, result.diagnostics()); + assertFalse(result.hasGeneratedSource("test/Plain_ForyJsonCodecMeta.java")); + assertFalse( + result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Plain.pro")); + } + + @Test + public void testPlainJsonType() throws Exception { + CompilationResult result = + compile( + "test.Plain", + "package test;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonType @JsonPropertyOrder({\"id\"}) public class Plain {\n" + + " @JsonProperty public int id;\n" + + " private Plain() {}\n" + + " @JsonCreator public Plain(@JsonProperty(\"id\") int id) { this.id = id; }\n" + + " public int getId() { return id; }\n" + + "}\n"); + assertTrue(result.success, result.diagnostics()); + assertFalse(result.hasGeneratedSource("test/Plain_ForyJsonCodecMeta.java")); + String rules = + result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Plain.pro"); + assertTrue(rules.contains("-keepattributes Signature,RuntimeVisibleAnnotations"), rules); + assertTrue(rules.contains("-keepattributes MethodParameters"), rules); + assertTrue(rules.contains("int id;"), rules); + assertTrue(rules.contains("int getId();"), rules); + assertTrue(rules.contains("();"), rules); + assertTrue(rules.contains("(int);"), rules); + assertFalse(rules.contains("_ForyJsonCodecMeta implements"), rules); + } + + @Test + public void testNestedMetadata() throws Exception { + CompilationResult result = compile("test.Outer_Name", nestedSource()); + assertTrue(result.success, result.diagnostics()); + String source = + result.generatedSource("test/Outer_Name$Model_With_Underscore_ForyJsonCodecMeta.java"); + assertTrue(source.contains("implements GeneratedJsonCodecMeta"), source); + assertTrue( + source.contains("private static final Map TYPE_USES"), source); + assertTrue(source.contains("JsonTypeUse.ARRAY_COMPONENT"), source); + assertTrue(source.contains("getDeclaredField(\"root\")"), source); + assertTrue(source.contains("getDeclaredField(\"values\")"), source); + assertTrue(source.contains("getDeclaredMethod(\"getInherited\""), source); + assertTrue(source.contains("getDeclaredMethod(\"getInterfaceValue\""), source); + assertFalse(source.contains("getSuppressedValues"), source); + assertFalse(source.contains("SuppressedCodec"), source); + assertTrue(source.contains("test.Outer_Name.Codec.class"), source); + assertFalse(source.contains("codecClass("), source); + assertFalse(source.contains("classForName("), source); + assertFalse(source.contains("import java.lang.reflect.*"), source); + assertFalse(source.contains("import java.util.*"), source); + + try (URLClassLoader loader = result.classLoader()) { + Class metaType = + loader.loadClass("test.Outer_Name$Model_With_Underscore_ForyJsonCodecMeta"); + GeneratedJsonCodecMeta meta = + metaType.asSubclass(GeneratedJsonCodecMeta.class).getConstructor().newInstance(); + Map first = meta.typeUses(); + Map second = meta.typeUses(); + assertTrue(first == second); + assertTrue(first.size() >= 3, first.toString()); + JsonTypeUse root = null; + for (Map.Entry entry : first.entrySet()) { + if (entry.getKey().getName().equals("root")) { + root = entry.getValue()[0]; + } + } + assertNotNull(root); + assertTrue(root.hasCodec()); + assertThrows(UnsupportedOperationException.class, () -> first.clear()); + } + + String rules = + result.generatedResource( + "META-INF/com.android.tools/r8/fory-json-test.Outer_Name$Model_With_Underscore.pro"); + assertTrue( + rules.contains( + "-keep,allowoptimization class test.Outer_Name$Model_With_Underscore_ForyJsonCodecMeta"), + rules); + assertTrue(rules.contains("-keepattributes InnerClasses,EnclosingMethod"), rules); + assertTrue(rules.contains("allowobfuscation class test.Outer_Name$Codec"), rules); + assertFalse(rules.contains("SuppressedCodec"), rules); + } + + @Test + public void testWildcardPath() throws Exception { + CompilationResult result = + compile( + "test.WildcardModel", + "package test;\n" + + "import java.util.*;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonType public class WildcardModel {\n" + + " public List values;\n" + + codecSource() + + "}\n"); + assertTrue(result.success, result.diagnostics()); + String source = result.generatedSource("test/WildcardModel_ForyJsonCodecMeta.java"); + assertTrue(source.contains("JsonTypeUse.WILDCARD_UPPER_BOUND"), source); + try (URLClassLoader loader = result.classLoader()) { + Class metaType = loader.loadClass("test.WildcardModel_ForyJsonCodecMeta"); + assertThrows( + ExceptionInInitializerError.class, () -> metaType.getConstructor().newInstance()); + } + } + + @Test + public void testSubtypeAndFallback() throws Exception { + CompilationResult result = + compile( + "test.Base", + "package test;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonType @JsonSubTypes({\n" + + " @JsonSubTypes.Type(value = Child.class, name = \"child\"),\n" + + " @JsonSubTypes.Type(className = \"external.HiddenChild\", name = \"hidden\")\n" + + "}) public abstract class Base {}\n" + + "class Child extends Base { public int value; }\n"); + assertTrue(result.success, result.diagnostics()); + assertTrue( + result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Base.pro")); + assertTrue( + result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Child.pro")); + String rules = + result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Base.pro"); + assertTrue(rules.contains("-keep,allowoptimization class external.HiddenChild"), rules); + } + + @Test + public void testDeclarationCodecRules() throws Exception { + CompilationResult result = + compile( + "test.DeclarationModel", + "package test;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonCodec(DeclarationModel.Codec.class) interface Contract {}\n" + + "@JsonType public class DeclarationModel implements Contract {\n" + + " private String value;\n" + + " @JsonCodec(Codec.class) public String getValue() { return value; }\n" + + codecSource() + + "}\n"); + assertTrue(result.success, result.diagnostics()); + assertFalse(result.hasGeneratedSource("test/DeclarationModel_ForyJsonCodecMeta.java")); + String rules = + result.generatedResource( + "META-INF/com.android.tools/r8/fory-json-test.DeclarationModel.pro"); + assertTrue(rules.contains("allowobfuscation class test.DeclarationModel$Codec"), rules); + assertTrue(rules.contains("public ();"), rules); + assertTrue(rules.contains("-keepattributes InnerClasses,EnclosingMethod"), rules); + } + + @Test + public void testInaccessibleCodecFallback() throws Exception { + CompilationResult result = + compile( + "test.HiddenCodecModel", + "package test;\n" + + "import java.util.*;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonType public class HiddenCodecModel {\n" + + " public List<@JsonCodec(Codec.class) String> values;\n" + + codecSource() + .replace("public static final class Codec", "private static final class Codec") + + "}\n"); + assertTrue(result.success, result.diagnostics()); + String source = result.generatedSource("test/HiddenCodecModel_ForyJsonCodecMeta.java"); + assertTrue(source.contains("codecClass(\"test.HiddenCodecModel$Codec\")"), source); + assertTrue(source.contains("classForName("), source); + String rules = + result.generatedResource( + "META-INF/com.android.tools/r8/fory-json-test.HiddenCodecModel.pro"); + assertTrue( + rules.contains( + "-keep,allowoptimization class test.HiddenCodecModel$Codec { public (); }"), + rules); + assertFalse(rules.contains("allowobfuscation class test.HiddenCodecModel$Codec"), rules); + } + + @Test + public void testIndependentDispatch() throws Exception { + CompilationResult result = + compile( + "test.Both", + "package test;\n" + + "import org.apache.fory.annotation.ForyStruct;\n" + + "import org.apache.fory.json.annotation.JsonType;\n" + + "@ForyStruct @JsonType public class Both { public int id; public Both() {} }\n"); + assertTrue(result.success, result.diagnostics()); + assertTrue(result.hasGeneratedSource("test/Both_ForySerializer.java")); + assertTrue( + result.hasGeneratedResource("META-INF/proguard/fory-static-generated-test.Both.pro")); + assertTrue( + result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Both.pro")); + } + + @Test + public void testEnumConstants() throws Exception { + CompilationResult result = + compile( + "test.Status", + "package test;\n" + + "import org.apache.fory.json.annotation.JsonType;\n" + + "@JsonType public enum Status { READY, DONE }\n"); + assertTrue(result.success, result.diagnostics()); + String rules = + result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Status.pro"); + assertTrue(rules.contains("test.Status READY;"), rules); + assertTrue(rules.contains("test.Status DONE;"), rules); + assertFalse(rules.contains("$VALUES"), rules); + } + + @Test + public void testValidationMemberRules() throws Exception { + CompilationResult result = + compile( + "test.ValidationModel", + "package test;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "class ValidationBase {\n" + + " @JsonProperty private String getHidden() { return null; }\n" + + "}\n" + + "@JsonType public class ValidationModel extends ValidationBase {\n" + + " public ValidationModel() {}\n" + + "}\n"); + assertTrue(result.success, result.diagnostics()); + String rules = + result.generatedResource( + "META-INF/com.android.tools/r8/fory-json-test.ValidationModel.pro"); + assertTrue(rules.contains("class test.ValidationBase"), rules); + assertTrue(rules.contains("java.lang.String getHidden();"), rules); + } + + @Test + public void testDeterministicOutput() throws Exception { + CompilationResult first = compile("test.Outer_Name", nestedSource()); + CompilationResult second = compile("test.Outer_Name", nestedSource()); + assertTrue(first.success, first.diagnostics()); + assertTrue(second.success, second.diagnostics()); + String metaPath = "test/Outer_Name$Model_With_Underscore_ForyJsonCodecMeta.java"; + String rulesPath = + "META-INF/com.android.tools/r8/fory-json-test.Outer_Name$Model_With_Underscore.pro"; + assertEquals(first.generatedSource(metaPath), second.generatedSource(metaPath)); + assertEquals(first.generatedResource(rulesPath), second.generatedResource(rulesPath)); + } + + private static String nestedSource() { + return "package test;\n" + + "import java.util.*;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "public class Outer_Name {\n" + + " public static class CodecBase {\n" + + " public java.lang.@JsonCodec(Codec.class) String root;\n" + + " public List<@JsonCodec(Codec.class) String[]> values;\n" + + " public List<@JsonCodec(Codec.class) String> getInherited() { return null; }\n" + + " }\n" + + " public interface Contract {\n" + + " default List<@JsonCodec(Codec.class) String> getInterfaceValue() { return null; }\n" + + " }\n" + + " public interface Suppressed {\n" + + " List<@JsonCodec(SuppressedCodec.class) String> getSuppressedValues();\n" + + " }\n" + + " @JsonType public static class Model_With_Underscore extends CodecBase implements Contract, Suppressed {\n" + + " public Model_With_Underscore() {}\n" + + " public List getSuppressedValues() { return null; }\n" + + " }\n" + + codecSource() + + codecSource("SuppressedCodec") + + "}\n"; + } + + private static String codecSource() { + return codecSource("Codec"); + } + + private static String codecSource(String name) { + return " public static final class " + + name + + " implements org.apache.fory.json.codec.JsonValueCodec {\n" + + " public " + + name + + "() {}\n" + + " public void writeString(org.apache.fory.json.writer.StringJsonWriter w, String v) {}\n" + + " public void writeUtf8(org.apache.fory.json.writer.Utf8JsonWriter w, String v) {}\n" + + " public String readLatin1(org.apache.fory.json.reader.Latin1JsonReader r) { return null; }\n" + + " public String readUtf16(org.apache.fory.json.reader.Utf16JsonReader r) { return null; }\n" + + " public String readUtf8(org.apache.fory.json.reader.Utf8JsonReader r) { return null; }\n" + + " }\n"; + } + + private static CompilationResult compile(String typeName, String source) throws IOException { + Map sources = new LinkedHashMap<>(); + sources.put(typeName, source); + return compile(sources); + } + + private static CompilationResult compile(Map sources) throws IOException { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Tests require a JDK compiler"); + Path root = Files.createTempDirectory("fory-json-processor-test"); + Path sourceRoot = root.resolve("src"); + Path classRoot = root.resolve("classes"); + Path generatedRoot = root.resolve("generated"); + Files.createDirectories(sourceRoot); + Files.createDirectories(classRoot); + Files.createDirectories(generatedRoot); + List sourceFiles = new ArrayList<>(); + for (Map.Entry source : sources.entrySet()) { + Path sourceFile = sourceRoot.resolve(source.getKey().replace('.', '/') + ".java"); + Files.createDirectories(sourceFile.getParent()); + Files.write(sourceFile, source.getValue().getBytes(StandardCharsets.UTF_8)); + sourceFiles.add(sourceFile.toFile()); + } + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + Iterable units = + fileManager.getJavaFileObjectsFromFiles(sourceFiles); + List options = + Arrays.asList( + "-classpath", + System.getProperty("java.class.path"), + "-d", + classRoot.toString(), + "-s", + generatedRoot.toString()); + JavaCompiler.CompilationTask task = + compiler.getTask(null, fileManager, diagnostics, options, null, units); + task.setProcessors(Collections.singletonList(new ForyStructProcessor())); + return new CompilationResult( + classRoot, generatedRoot, task.call(), diagnostics.getDiagnostics()); + } + } + + private static final class CompilationResult { + final Path classRoot; + final Path generatedRoot; + final boolean success; + final List> diagnostics; + + CompilationResult( + Path classRoot, + Path generatedRoot, + boolean success, + List> diagnostics) { + this.classRoot = classRoot; + this.generatedRoot = generatedRoot; + this.success = success; + this.diagnostics = new ArrayList<>(diagnostics); + } + + URLClassLoader classLoader() throws IOException { + URL[] urls = {classRoot.toUri().toURL()}; + return new URLClassLoader(urls, JsonTypeProcessorTest.class.getClassLoader()); + } + + boolean hasGeneratedSource(String relativePath) { + return Files.exists(generatedRoot.resolve(relativePath)); + } + + boolean hasGeneratedResource(String relativePath) { + return Files.exists(classRoot.resolve(relativePath)); + } + + String generatedSource(String relativePath) throws IOException { + return new String( + Files.readAllBytes(generatedRoot.resolve(relativePath)), StandardCharsets.UTF_8); + } + + String generatedResource(String relativePath) throws IOException { + return new String( + Files.readAllBytes(classRoot.resolve(relativePath)), StandardCharsets.UTF_8); + } + + String diagnostics() { + StringBuilder builder = new StringBuilder(); + for (Diagnostic diagnostic : diagnostics) { + builder.append(diagnostic).append('\n'); + } + return builder.toString(); + } + } +} diff --git a/java/fory-json/README.md b/java/fory-json/README.md index 4874abf8d1..c3e23eb114 100644 --- a/java/fory-json/README.md +++ b/java/fory-json/README.md @@ -356,18 +356,20 @@ operation. Apply request/body size limits at the transport boundary when parsing Builder mutation after `build()` does not modify an existing `ForyJson` runtime. -In a GraalVM native image, runtime code generation and asynchronous compilation are automatically -disabled. Every other builder option keeps the behavior described above. +On Android and in a GraalVM native image, runtime code generation and asynchronous compilation are +automatically disabled. Every other builder option keeps the behavior described above. ## JSON annotations Fory JSON defines ten annotations in `org.apache.fory.json.annotation`, including `JsonCodec` for -complete-value codec selection and `JsonType` for GraalVM Native Image model metadata. They are Fory -JSON APIs, not Jackson, Gson, or Fory binary-protocol compatibility annotations. +complete-value codec selection and `JsonType` for GraalVM Native Image and Android build metadata. +They are Fory JSON APIs, not Jackson, Gson, or Fory binary-protocol compatibility annotations. `JsonType` has no effect on ordinary JVM JSON behavior and is not inherited. Add it to every -reachable object model used by a native executable. See the -[GraalVM guide](../../docs/guide/java/graalvm-support.md) for the complete workflow. +reachable object model used by a native executable. On Android it enables processor-generated R8 +rules and `JsonCodec` type-use metadata. See the +[GraalVM guide](../../docs/guide/java/graalvm-support.md) and +[Android guide](../../docs/guide/java/android-support.md) for the platform workflows. ### `JsonProperty` @@ -860,6 +862,26 @@ through both superclasses and interfaces, so `RetailAccount` uses `AccountCodec` higher-priority choice overrides it. Records and enums use the same declaration form. Fory does not rely on Java `@Inherited`, which does not cover interfaces. +A declaration on a field or effective ordinary getter selects the codec for that member's root +value: + +```java +public final class Invoice { + @JsonCodec(MoneyCodec.class) + public Money total; + + @JsonCodec(MoneyCodec.class) + public Money getTax() { + return tax; + } +} +``` + +The method form is also valid on an effective record accessor. It is invalid on setters, creator +factories, unrelated methods, and void methods. An ordinary getter declaration is invalid when +field mode disables getter discovery; record accessors continue to follow the existing record owner +path. Fory checks the field or method declaration before falling back to the root annotated type. + Use `@JsonCodec` at a type-use position to change one exact occurrence while retaining the normal mapping around it: @@ -872,7 +894,8 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; public final class Invoice { - public @JsonCodec(MoneyCodec.class) Money total; + // Qualified placement selects root TYPE_USE rather than the field declaration. + public com.example.@JsonCodec(MoneyCodec.class) Money qualifiedTotal; public List<@JsonCodec(MoneyCodec.class) Money> items; public Set<@JsonCodec(MoneyCodec.class) Money> uniqueItems; @@ -882,7 +905,7 @@ public final class Invoice { public AtomicReference<@JsonCodec(MoneyCodec.class) Money> current; // Codec for each Money element. - public @JsonCodec(MoneyCodec.class) Money[] itemArray; + public com.example.@JsonCodec(MoneyCodec.class) Money[] itemArray; // Codec for the complete Money[] value. public Money @JsonCodec(MoneyArrayCodec.class) [] encodedArray; @@ -918,18 +941,22 @@ discovered model property, declare the default on `Money`, or use exact builder For the current resolved value node and target class, the first applicable row wins. An invalid higher-priority configuration fails instead of falling back. -| Priority | Source | Match | -| -------: | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| 1 | Current `TYPE_USE @JsonCodec` | This exact resolved occurrence after property merging and generic substitution | -| 2 | `registerCodec(Target.class, instance)` | Exact target `Class`; registrations are not inherited | -| 3 | `@JsonCodec` declared directly on the current class, record, enum, or interface | Exact current declaration | -| 4 | Inherited class/interface `@JsonCodec` | Deterministic most-specific declaration described below | -| 5 | Existing mapping | `JsonSubTypes`, built-in/container mapping, then default object mapping | +| Priority | Source | Match | +| -------: | --------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| 1 | Current member or `TYPE_USE @JsonCodec` | Exact resolved occurrence after declaration-first acquisition, property merging, and generic substitution | +| 2 | `registerCodec(Target.class, instance)` | Exact target `Class`; registrations are not inherited | +| 3 | Direct type `@JsonCodec` declaration | Exact current class, record, enum, or interface | +| 4 | Inherited type declaration | Deterministic most-specific declaration described below | +| 5 | Existing mapping | `JsonSubTypes`, built-in/container mapping, then default object mapping | + +For each field or getter, its declaration takes precedence only over that same member's root +annotated type. The selected field, getter, setter, record component, and creator occurrences are +then merged as one logical property; different codecs at the same node remain a conflict. Any choice in rows 1 through 4 replaces the complete value mapping, including a built-in, -container, enum, `JsonSubTypes`, or object mapping. A current type-use, exact builder registration, -or direct declaration also resolves an otherwise ambiguous inherited configuration without -evaluating that lower-priority conflict. +container, enum, `JsonSubTypes`, or object mapping. A current occurrence, exact builder +registration, or direct declaration also resolves an otherwise ambiguous inherited configuration +without evaluating that lower-priority conflict. Builder registration is exact-class only and remains the path for configured codec instances. Registering a codec for a parent or interface does not apply it to descendants. @@ -1050,13 +1077,14 @@ map-value, Optional, and atomic-reference codecs resolve their annotated childre `@JsonCodec` is a value-codec annotation. An explicit occurrence anywhere in a Map key subtree is rejected; declaration defaults and builder registrations for a key type are ignored in key -position. They still apply when that type appears as a JSON value. For `JsonAnyProperty`, -`JsonAnyGetter`, or `JsonAnySetter`, only the Map value may select a value codec because the outer -Map is flattened into its containing object. Wildcard nodes, wildcard bounds, and type variables -that remain unresolved after substitution cannot select a codec. A type-use written on an -`extends` or `implements` clause is a hierarchy path rather than a JSON value occurrence and is not -used; put the annotation on the type declaration instead. Annotation type declarations are not -supported JSON model targets. +position. They still apply when that type appears as a JSON value. `JsonAnyProperty` and +`JsonAnyGetter` flatten their Map into the containing object, so only the nested Map value may +select a codec; a field or method declaration codec and a root type-use codec are invalid on those +two forms. A `JsonAnySetter` value parameter is already one flattened value and may carry a root +type-use codec. Wildcard nodes, wildcard bounds, and type variables that remain unresolved after +substitution cannot select a codec. A type-use written on an `extends` or `implements` clause is a +hierarchy path rather than a JSON value occurrence and is not used; put the annotation on the type +declaration instead. Annotation type declarations are not supported JSON model targets. ### Construction, inherited results, and platforms @@ -1079,10 +1107,16 @@ and actual returned class. Fory validates the actual result rather than rejectin on the codec's generic signature. `@JsonCodec` declaration and type-use discovery is supported on ordinary JVMs and GraalVM native -images, including inherited declarations and nested type uses. Native object models must follow the -`JsonType` workflow in the [GraalVM guide](../../docs/guide/java/graalvm-support.md), which registers -the annotation codec's public no-argument constructor. Android ignores annotation codec sources; -use exact `registerCodec(Target.class, instance)` registration there. +images, including member declarations, inherited type declarations, and nested type uses. Native +object models must follow the `JsonType` workflow in the +[GraalVM guide](../../docs/guide/java/graalvm-support.md), which registers the annotation codec's +public no-argument constructor. + +Android directly reads type, field, and effective getter declarations. Pure type-use locations, +including qualified roots, parameters, generic arguments, and array components, require `JsonType` +processor metadata. `JsonType` also supplies automatic R8 rules; applications that omit it can +supply exact rules manually and use declaration syntax, but type-use codecs remain unavailable. See +the [Android guide](../../docs/guide/java/android-support.md). ## Type validation and untrusted input diff --git a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java index 64b3e446a1..eed84065d4 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java @@ -22,6 +22,7 @@ import java.util.Objects; import org.apache.fory.json.codec.JsonValueCodec; import org.apache.fory.json.resolver.CodecRegistry; +import org.apache.fory.platform.AndroidSupport; import org.apache.fory.platform.GraalvmSupport; /** @@ -33,12 +34,12 @@ * Latin1 reader, UTF16 reader, and UTF-8 reader paths; asynchronous compilation controls when those * path-specific replacements are installed, not codec semantics. * - *

Defaults omit null object fields, enable code generation and asynchronous compilation, use - * JavaBean property discovery, use {@link PropertyNamingStrategy#LOWER_CAMEL_CASE}, snapshot the - * current thread context class loader, allow a nesting depth of 20, use twice the available - * processors as the pooled-state concurrency level, retain writer buffers up to 2 MiB, and install - * no custom type checker. Field mode disables getter and setter discovery but continues to discover - * eligible instance fields across the class hierarchy. + *

Defaults omit null object fields, enable code generation and asynchronous compilation where + * supported, use JavaBean property discovery, use {@link PropertyNamingStrategy#LOWER_CAMEL_CASE}, + * snapshot the current thread context class loader, allow a nesting depth of 20, use twice the + * available processors as the pooled-state concurrency level, retain writer buffers up to 2 MiB, + * and install no custom type checker. Field mode disables getter and setter discovery but continues + * to discover eligible instance fields across the class hierarchy. */ public final class ForyJsonBuilder { private boolean writeNullFields; @@ -69,7 +70,7 @@ public ForyJsonBuilder writeNullFields(boolean writeNullFields) { /** * Enables generated object codecs for supported classes. Enabled by default and automatically - * disabled in a GraalVM native image. + * disabled on Android and in a GraalVM native image. */ public ForyJsonBuilder withCodegen(boolean codegenEnabled) { this.codegenEnabled = codegenEnabled; @@ -187,7 +188,8 @@ public ForyJson build() { fixedClassLoader = ForyJson.class.getClassLoader(); } } - boolean effectiveCodegen = codegenEnabled && !GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE; + boolean effectiveCodegen = + codegenEnabled && !AndroidSupport.IS_ANDROID && !GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE; boolean effectiveAsyncCompilation = asyncCompilationEnabled && effectiveCodegen; return new ForyJson( new JsonConfig( diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java index 1df86a175f..714cfaf13d 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java @@ -27,7 +27,8 @@ import org.apache.fory.json.codec.JsonValueCodec; /** - * Selects the complete JSON value codec for a type declaration or one exact type-use occurrence. + * Selects the complete JSON value codec for a type declaration, field, readable method, or one + * exact type-use occurrence. * *

On a class, record, enum, or interface declaration, this annotation defines an inheritable * JSON representation contract. Fory traverses both superclass and interface declarations @@ -37,6 +38,14 @@ * declaration, a codec on the current type use, or an exact builder registration can disambiguate * such a hierarchy. * + *

On a field, this annotation selects the codec for the field's root value. On a method, it is + * valid only on an effective ordinary JSON getter or record accessor and selects the codec for the + * return value. A {@link org.apache.fory.json.annotation.JsonAnyProperty JsonAnyProperty} field and + * a {@link org.apache.fory.json.annotation.JsonAnyGetter JsonAnyGetter} are flattened into their + * owning object and therefore cannot have a complete root codec, although their nested map values + * may have type-use codecs. A declaration annotation takes precedence over an annotation on the + * corresponding root type use. + * *

On a type use, this annotation applies only to that resolved value node. It can select the * complete codec for a field, accessor or creator value, an array dimension or component, or a * nested collection, map-value, optional, or atomic-reference node. It does not apply to map keys, @@ -47,7 +56,7 @@ */ @Documented @Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE, ElementType.TYPE_USE}) +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE}) public @interface JsonCodec { /** Returns the public, concrete codec class selected for this declaration or occurrence. */ Class> value(); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java index dc9f408676..83e5e44bbc 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java @@ -26,11 +26,18 @@ import java.lang.annotation.Target; /** - * Marks a reachable JSON model for reflection metadata registration in a GraalVM native image. + * Marks a reachable JSON model for optional build-time metadata generation. * - *

The annotation does not change JSON behavior on the JVM and is intentionally not inherited. - * Annotate every runtime model type that Fory JSON reads or writes. A class-literal subtype listed - * by an annotated {@link JsonSubTypes} base is registered automatically. + *

When the Fory annotation processor is enabled, it generates exact R8 rules for the model and, + * when needed, Android metadata for {@link JsonCodec} type uses. A class-literal subtype listed by + * an annotated {@link JsonSubTypes} base is processed automatically. The annotation is also used + * for reflection metadata registration in GraalVM native images. + * + *

This annotation does not change ordinary JVM behavior and is intentionally not inherited. It + * is not required for Android runtime use: applications may supply equivalent R8 rules themselves. + * Without generated metadata, Android supports type, field, and readable-method declaration {@link + * JsonCodec} annotations, but not pure type-use locations such as a qualified root, generic + * argument, parameter type, or array component. */ @Documented @Retention(RetentionPolicy.RUNTIME) diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java index 71c44dd7dc..4d9e0b3594 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java @@ -22,6 +22,7 @@ import java.lang.invoke.MethodHandle; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Member; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Type; @@ -112,25 +113,21 @@ private ObjectCodec( } } - @Internal - public static ObjectCodec build( - TypeRef ownerType, - boolean propertyDiscoveryEnabled, - PropertyNamingStrategy propertyNamingStrategy, - boolean writeNullFields) { - return build( - ownerType, null, propertyDiscoveryEnabled, propertyNamingStrategy, writeNullFields); - } - @Internal public static ObjectCodec build( TypeRef ownerType, JsonTypeUse ownerTypeUse, + Map generatedTypeUses, boolean propertyDiscoveryEnabled, PropertyNamingStrategy propertyNamingStrategy, boolean writeNullFields) { return ObjectCodecBuilder.build( - ownerType, ownerTypeUse, propertyDiscoveryEnabled, propertyNamingStrategy, writeNullFields); + ownerType, + ownerTypeUse, + generatedTypeUses, + propertyDiscoveryEnabled, + propertyNamingStrategy, + writeNullFields); } static ObjectCodec createCodec( diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java index 60e8cd1d60..9803cbf0b2 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java @@ -23,6 +23,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Field; +import java.lang.reflect.Member; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; @@ -40,6 +41,7 @@ import org.apache.fory.json.annotation.JsonAnyGetter; import org.apache.fory.json.annotation.JsonAnyProperty; import org.apache.fory.json.annotation.JsonAnySetter; +import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonProperty; @@ -64,6 +66,7 @@ private ObjectCodecBuilder() {} static ObjectCodec build( TypeRef ownerType, JsonTypeUse ownerTypeUse, + Map generatedTypeUses, boolean propertyDiscoveryEnabled, PropertyNamingStrategy propertyNamingStrategy, boolean writeNullFields) { @@ -78,11 +81,12 @@ static ObjectCodec build( boolean record = RecordUtils.isRecord(type); boolean hasAnyField = validateMemberAnnotations(type, propertyDiscoveryEnabled, record); LinkedHashMap builders = new LinkedHashMap<>(); - addFields(type, record, propertyDiscoveryEnabled, hasAnyField, builders); + addFields(type, record, propertyDiscoveryEnabled, hasAnyField, generatedTypeUses, builders); if (record) { addRecordAccessors(type, builders); } - Method anySetter = addJsonMethods(type, propertyDiscoveryEnabled, record, builders); + Method anySetter = + addJsonMethods(type, propertyDiscoveryEnabled, record, generatedTypeUses, builders); FieldBuilder anyBuilder = findAnyBuilder(type, builders); if (anyBuilder != null && anyBuilder.anyField != null) { if (anyBuilder.anyGetter != null || anySetter != null) { @@ -101,7 +105,8 @@ boolean record = RecordUtils.isRecord(type); JsonCreatorInfo creatorInfo = record ? rejectRecordCreator(type) - : buildCreatorInfo(type, ownerType, ownerTypeUse, builders, propertyNamingStrategy); + : buildCreatorInfo( + type, ownerType, ownerTypeUse, generatedTypeUses, builders, propertyNamingStrategy); if (anySetter != null && (record || creatorInfo != null)) { throw new ForyJsonException( "@JsonAnySetter is not supported on constructor-backed type " + type.getName()); @@ -265,7 +270,13 @@ boolean record = RecordUtils.isRecord(type); AnyInfo anyInfo = hasAny ? buildAnyInfo( - ownerType, ownerTypeUse, anyBuilder, anySetter, anyWriteIndex, constructionIndex) + ownerType, + ownerTypeUse, + generatedTypeUses, + anyBuilder, + anySetter, + anyWriteIndex, + constructionIndex) : null; ObjectInstantiator instantiator = ObjectInstantiators.createObjectInstantiator(type); String[] recordNames = record ? readJavaNames.toArray(new String[0]) : null; @@ -550,6 +561,7 @@ private static void addFields( boolean record, boolean propertyDiscoveryEnabled, boolean hasAnyField, + Map generatedTypeUses, LinkedHashMap builders) { List> hierarchy = new ArrayList<>(); for (Class current = type; @@ -576,7 +588,8 @@ private static void addFields( continue; } FieldBuilder builder = - builders.computeIfAbsent(field.getName(), name -> new FieldBuilder(name)); + builders.computeIfAbsent( + field.getName(), name -> new FieldBuilder(name, generatedTypeUses)); builder.setField(type, field, write, read, write, readAllowed); } } @@ -586,6 +599,7 @@ private static Method addJsonMethods( Class type, boolean propertyDiscoveryEnabled, boolean record, + Map generatedTypeUses, LinkedHashMap builders) { Method anyGetter = null; Method anySetter = null; @@ -630,7 +644,7 @@ private static Method addJsonMethods( if (propertyName != null) { FieldBuilder builder = builders.get(propertyName); if (builder == null) { - builder = new FieldBuilder(propertyName); + builder = new FieldBuilder(propertyName, generatedTypeUses); builders.put(propertyName, builder); } builder.setWriteGetter(type, method); @@ -640,7 +654,7 @@ private static Method addJsonMethods( if (propertyName != null) { FieldBuilder builder = builders.get(propertyName); if (builder == null) { - builder = new FieldBuilder(propertyName); + builder = new FieldBuilder(propertyName, generatedTypeUses); builders.put(propertyName, builder); } builder.setReadSetter(type, method); @@ -653,7 +667,7 @@ private static Method addJsonMethods( } FieldBuilder builder = builders.get(propertyName); if (builder == null) { - builder = new FieldBuilder(propertyName); + builder = new FieldBuilder(propertyName, generatedTypeUses); builders.put(propertyName, builder); } builder.setAnyGetter(type, anyGetter); @@ -741,6 +755,7 @@ private static JsonCreatorInfo buildCreatorInfo( Class type, TypeRef ownerType, JsonTypeUse ownerTypeUse, + Map generatedTypeUses, LinkedHashMap builders, PropertyNamingStrategy namingStrategy) { Executable creator = null; @@ -887,7 +902,7 @@ private static JsonCreatorInfo buildCreatorInfo( ownerType, ownerTypeUse, parameterTypes[i], - executableParameterTypeUse(creator, i), + executableParameterTypeUse(creator, i, generatedTypeUses), "creator property " + jsonName); } else { typeUse = builder.resolveTypeUse(ownerType, ownerTypeUse, "creator property " + jsonName); @@ -957,10 +972,16 @@ private static void bindCreatorType( builder.setCreatorType(creator, parameterIndex, parameterType); } - private static JsonTypeUse executableParameterTypeUse(Executable executable, int parameterIndex) { - return executable instanceof Method - ? JsonTypeUse.forMethodParameter((Method) executable, parameterIndex) - : JsonTypeUse.forConstructorParameter((Constructor) executable, parameterIndex); + private static JsonTypeUse executableParameterTypeUse( + Executable executable, int parameterIndex, Map generatedTypeUses) { + JsonTypeUse reflected = + executable instanceof Method + ? JsonTypeUse.forMethodParameter((Method) executable, parameterIndex) + : JsonTypeUse.forConstructorParameter((Constructor) executable, parameterIndex); + return JsonTypeUse.merge( + reflected, + generatedTypeUse(generatedTypeUses, executable, parameterIndex + 1), + executable + " parameter " + parameterIndex); } private static void rejectCreatorHashCollisions(JsonCreatorFieldInfo[] fields) { @@ -1006,6 +1027,9 @@ private static boolean validateMemberAnnotations( current != null && current != Object.class; current = current.getSuperclass()) { for (Field field : current.getDeclaredFields()) { + if (field.isAnnotationPresent(JsonCodec.class) && !isEligibleField(field)) { + throw new ForyJsonException("@JsonCodec is not supported on JSON field: " + field); + } if (field.isAnnotationPresent(JsonProperty.class) && !isEligibleField(field)) { throw new ForyJsonException("@JsonProperty is not supported on JSON field: " + field); } @@ -1026,6 +1050,9 @@ private static boolean validateMemberAnnotations( if (isOverridden(type, method)) { continue; } + if (method.isAnnotationPresent(JsonCodec.class)) { + validateCodecMethod(type, method, propertyDiscoveryEnabled, record); + } if (method.isAnnotationPresent(JsonProperty.class)) { validatePropertyMethod(type, method, propertyDiscoveryEnabled, record); } @@ -1045,9 +1072,26 @@ private static boolean validateMemberAnnotations( } } } + for (Method method : type.getMethods()) { + if (method.getDeclaringClass().isInterface() && method.isAnnotationPresent(JsonCodec.class)) { + // getMethods exposes only the effective inherited declaration. A class or child-interface + // override therefore suppresses an annotation from the overridden interface method. + validateCodecMethod(type, method, propertyDiscoveryEnabled, record); + } + } return hasAnyField; } + private static void validateCodecMethod( + Class type, Method method, boolean propertyDiscoveryEnabled, boolean record) { + boolean readable = + record && isRecordAccessor(type, method) || !record && getterPropertyName(method) != null; + if ((!record && !propertyDiscoveryEnabled) || !isEligibleAccessor(method) || !readable) { + throw new ForyJsonException( + "@JsonCodec requires an effective ordinary JSON getter or record accessor: " + method); + } + } + private static boolean isOverridden(Class type, Method method) { int modifiers = method.getModifiers(); if (method.getDeclaringClass() == type @@ -1176,6 +1220,7 @@ private static String decapitalize(String name) { private static AnyInfo buildAnyInfo( TypeRef ownerType, JsonTypeUse ownerTypeUse, + Map generatedTypeUses, FieldBuilder builder, Method anySetter, int writeIndex, @@ -1237,7 +1282,10 @@ private static AnyInfo buildAnyInfo( ownerType, ownerTypeUse, anySetter.getGenericParameterTypes()[0], - JsonTypeUse.forMethodParameter(anySetter, 0), + JsonTypeUse.merge( + JsonTypeUse.forMethodParameter(anySetter, 0), + generatedTypeUse(generatedTypeUses, anySetter, 1), + "JSON Any setter key"), "JSON Any setter key"); if (setterKeyUse != null) { setterKeyUse.rejectMapKey("JSON Any setter"); @@ -1247,7 +1295,10 @@ private static AnyInfo buildAnyInfo( ownerType, ownerTypeUse, anySetter.getGenericParameterTypes()[1], - JsonTypeUse.forMethodParameter(anySetter, 1), + JsonTypeUse.merge( + JsonTypeUse.forMethodParameter(anySetter, 1), + generatedTypeUse(generatedTypeUses, anySetter, 2), + "JSON Any setter value"), "JSON Any setter value"); valueTypeUse = JsonTypeUse.merge(valueTypeUse, setterValueUse, "JSON Any value on " + ownerType); @@ -1345,8 +1396,15 @@ private static Type boxedType(Type type) { return Character.class; } + private static JsonTypeUse generatedTypeUse( + Map generatedTypeUses, Member member, int slot) { + JsonTypeUse[] slots = generatedTypeUses.get(member); + return slots == null ? null : slots[slot]; + } + private static final class FieldBuilder { private final String name; + private final Map generatedTypeUses; private Field field; private boolean fieldWriteAllowed; private boolean fieldReadAllowed; @@ -1374,8 +1432,9 @@ private static final class FieldBuilder { private JsonTypeUse creatorTypeUse; private Type creatorType; - private FieldBuilder(String name) { + private FieldBuilder(String name, Map generatedTypeUses) { this.name = name; + this.generatedTypeUses = generatedTypeUses; } private void setField( @@ -1389,7 +1448,11 @@ private void setField( throw new ForyJsonException("Duplicate JSON field " + name); } this.field = field; - fieldTypeUse = JsonTypeUse.forField(field); + fieldTypeUse = + JsonTypeUse.merge( + JsonTypeUse.forField(field), + generatedTypeUse(generatedTypeUses, field, 0), + "field " + field); fieldWriteAllowed = writeAllowed; fieldReadAllowed = readAllowed; if (writeSource) { @@ -1409,7 +1472,11 @@ private void setField( private void setWriteGetter(Class type, Method getter) { mergeAnnotation(type, getter); - writeGetterTypeUse = JsonTypeUse.forMethodReturn(getter); + writeGetterTypeUse = + JsonTypeUse.merge( + JsonTypeUse.forMethodReturn(getter), + generatedTypeUse(generatedTypeUses, getter, 0), + "getter " + getter); if (field != null && !fieldWriteAllowed) { return; } @@ -1422,7 +1489,11 @@ private void setWriteGetter(Class type, Method getter) { private void setReadSetter(Class type, Method setter) { mergeAnnotation(type, setter); - readSetterTypeUse = JsonTypeUse.forMethodParameter(setter, 0); + readSetterTypeUse = + JsonTypeUse.merge( + JsonTypeUse.forMethodParameter(setter, 0), + generatedTypeUse(generatedTypeUses, setter, 1), + "setter " + setter); if (field != null && !fieldReadAllowed) { return; } @@ -1435,7 +1506,11 @@ private void setReadSetter(Class type, Method setter) { private void setAnyGetter(Class type, Method getter) { mergeAnnotation(type, getter); - writeGetterTypeUse = JsonTypeUse.forMethodReturn(getter); + writeGetterTypeUse = + JsonTypeUse.merge( + JsonTypeUse.forMethodReturn(getter), + generatedTypeUse(generatedTypeUses, getter, 0), + "JSON Any getter " + getter); if (field != null && !fieldWriteAllowed) { throw new ForyJsonException( "@JsonIgnore disables the same-name @JsonAnyGetter on " + getter); @@ -1565,7 +1640,7 @@ private void setRecordComponent(RecordComponent component) { private void setCreatorType(Executable creator, int parameterIndex, Type parameterType) { creatorType = parameterType; - creatorTypeUse = executableParameterTypeUse(creator, parameterIndex); + creatorTypeUse = executableParameterTypeUse(creator, parameterIndex, generatedTypeUses); } private JsonTypeUse resolveTypeUse( diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/GeneratedJsonCodecMeta.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/GeneratedJsonCodecMeta.java new file mode 100644 index 0000000000..9c85c46183 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/GeneratedJsonCodecMeta.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.json.meta; + +import java.lang.reflect.Member; +import java.util.Map; +import org.apache.fory.annotation.Internal; + +/** Build-time generated {@code JsonCodec} type-use metadata for one JSON model. */ +@Internal +public interface GeneratedJsonCodecMeta { + /** Returns the single immutable member metadata map owned by the generated companion. */ + Map typeUses(); +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonTypeUse.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonTypeUse.java index 2e179a5b6d..ec16dee10c 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonTypeUse.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonTypeUse.java @@ -19,6 +19,7 @@ package org.apache.fory.json.meta; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.GenericArrayType; @@ -47,15 +48,24 @@ /** * Immutable cold metadata for source-explicit {@link JsonCodec} annotations on one Java type use. * - *

Declaration annotations are intentionally absent. They remain lazy defaults of a raw target - * class and are resolved only when a value owner delegates to that node. Instances are retained - * only when the complete occurrence tree contains an explicit codec annotation. + *

Raw-type declaration annotations are intentionally absent. They remain lazy defaults of a raw + * target class and are resolved only when a value owner delegates to that node. Field and method + * declarations are explicit annotations on the owned occurrence root. Instances are retained only + * when the complete occurrence tree contains an explicit codec annotation. */ @Internal public final class JsonTypeUse { private static final JsonTypeUse[] NO_CHILDREN = new JsonTypeUse[0]; private static final String[] NO_SOURCES = new String[0]; - private static final boolean ANNOTATIONS_SUPPORTED = !AndroidSupport.IS_ANDROID; + + /** Generated-path step selecting the component of an array type. */ + public static final int ARRAY_COMPONENT = -1; + + /** Generated-path step identifying the upper bound of a wildcard. */ + public static final int WILDCARD_UPPER_BOUND = -2; + + /** Generated-path step identifying the lower bound of a wildcard. */ + public static final int WILDCARD_LOWER_BOUND = -3; private final Type type; private final Class rawType; @@ -93,54 +103,66 @@ private JsonTypeUse( hashCode = structuralHashCode(); } - /** Returns whether JSON codec annotations are supported on the current platform. */ - public static boolean annotationsSupported() { - return ANNOTATIONS_SUPPORTED; - } - /** Returns the explicit type-use tree for {@code field}, or {@code null} when none is present. */ public static JsonTypeUse forField(Field field) { - if (!ANNOTATIONS_SUPPORTED) { - return null; + String source = "field " + field.getDeclaringClass().getName() + "." + field.getName(); + JsonCodec declaration = memberAnnotation(field, source); + if (AndroidSupport.IS_ANDROID) { + // Android API 26 exposes declaration annotations but none of the Java 8 AnnotatedType + // methods on Field, Method, or Executable. Pure type-use facts come from the generated map. + return rootTypeUse(field.getGenericType(), declaration, source); } - return fromTypeUse( - TypeUseMetadata.fieldTypeUse(field), - "field " + field.getDeclaringClass().getName() + "." + field.getName()); + Type type = field.getGenericType(); + if (declaration != null && !hasNestedTypeShape(type)) { + return rootTypeUse(type, declaration, source); + } + return declarationFirst(type, declaration, fieldTypeUse(field, source), source); } /** Returns the explicit return type-use tree for {@code method}, or {@code null}. */ public static JsonTypeUse forMethodReturn(Method method) { - if (!ANNOTATIONS_SUPPORTED) { - return null; + String source = + "method return " + method.getDeclaringClass().getName() + "#" + method.getName(); + JsonCodec declaration = memberAnnotation(method, source); + if (AndroidSupport.IS_ANDROID) { + return rootTypeUse(method.getGenericReturnType(), declaration, source); } - return fromTypeUse( - TypeUseMetadata.methodReturnTypeUse(method), - "method return " + method.getDeclaringClass().getName() + "#" + method.getName()); + Type type = method.getGenericReturnType(); + if (declaration != null && !hasNestedTypeShape(type)) { + return rootTypeUse(type, declaration, source); + } + return declarationFirst(type, declaration, methodReturnTypeUse(method, source), source); } /** Returns one explicit method-parameter type-use tree, or {@code null}. */ public static JsonTypeUse forMethodParameter(Method method, int parameterIndex) { - if (!ANNOTATIONS_SUPPORTED) { - checkParameterIndex(null, method.getParameterCount(), parameterIndex, method.toString()); - return null; - } - Object[] parameters = TypeUseMetadata.methodParameterTypeUses(method); - checkParameterIndex(parameters, method.getParameterCount(), parameterIndex, method.toString()); - return fromTypeUse( - parameters == null ? null : parameters[parameterIndex], + String source = "method parameter " + method.getDeclaringClass().getName() + "#" + method.getName() + "[" + parameterIndex - + "]"); + + "]"; + if (AndroidSupport.IS_ANDROID) { + checkParameterIndex(null, method.getParameterCount(), parameterIndex, method.toString()); + return null; + } + Object[] parameters = TypeUseMetadata.methodParameterTypeUses(method); + checkParameterIndex(parameters, method.getParameterCount(), parameterIndex, method.toString()); + return fromTypeUse(parameters == null ? null : parameters[parameterIndex], source); } /** Returns one explicit constructor-parameter type-use tree, or {@code null}. */ public static JsonTypeUse forConstructorParameter( Constructor constructor, int parameterIndex) { - if (!ANNOTATIONS_SUPPORTED) { + String source = + "constructor parameter " + + constructor.getDeclaringClass().getName() + + "[" + + parameterIndex + + "]"; + if (AndroidSupport.IS_ANDROID) { checkParameterIndex( null, constructor.getParameterCount(), parameterIndex, constructor.toString()); return null; @@ -148,18 +170,12 @@ public static JsonTypeUse forConstructorParameter( Object[] parameters = TypeUseMetadata.constructorParameterTypeUses(constructor); checkParameterIndex( parameters, constructor.getParameterCount(), parameterIndex, constructor.toString()); - return fromTypeUse( - parameters == null ? null : parameters[parameterIndex], - "constructor parameter " - + constructor.getDeclaringClass().getName() - + "[" - + parameterIndex - + "]"); + return fromTypeUse(parameters == null ? null : parameters[parameterIndex], source); } /** Returns the explicit type-use tree for {@code component}, or {@code null}. */ public static JsonTypeUse forRecordComponent(RecordComponent component) { - if (!ANNOTATIONS_SUPPORTED) { + if (AndroidSupport.IS_ANDROID) { return null; } return fromTypeUse( @@ -170,12 +186,12 @@ public static JsonTypeUse forRecordComponent(RecordComponent component) { /** * Builds an explicit tree from a platform-neutral type-use handle. * - *

The returned value is {@code null} when annotation support is disabled or no codec occurs at - * any node. {@code source} identifies the field, accessor, record component, or creator parameter - * in diagnostics. + *

The returned value is {@code null} when recursive type-use metadata is unavailable or no + * codec occurs at any node. {@code source} identifies the field, accessor, record component, or + * creator parameter in diagnostics. */ public static JsonTypeUse fromTypeUse(Object typeUse, String source) { - if (!ANNOTATIONS_SUPPORTED || typeUse == null) { + if (AndroidSupport.IS_ANDROID || typeUse == null) { return null; } if (!scanExplicit(typeUse, source, "$")) { @@ -184,6 +200,24 @@ public static JsonTypeUse fromTypeUse(Object typeUse, String source) { return buildTypeUse(typeUse, source, "$"); } + /** + * Builds one generated codec tree from an ordinary retained reflection type. + * + *

An empty path selects the root. Each path step is otherwise a non-negative generic-argument + * index, {@link #ARRAY_COMPONENT}, {@link #WILDCARD_UPPER_BOUND}, or {@link + * #WILDCARD_LOWER_BOUND}. Wildcard steps reproduce the existing rejection for codecs on wildcards + * and their bounds rather than silently dropping invalid source annotations on platforms without + * recursive type-use reflection. + */ + public static JsonTypeUse generated( + Type rootType, Class> codecClass, String source, int... path) { + Objects.requireNonNull(rootType, "rootType"); + Objects.requireNonNull(codecClass, "codecClass"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(path, "path"); + return applyGenerated(plainType(rootType), codecClass, source, path, 0, "$"); + } + /** * Resolves one declared member type and its explicit tree against its parameterized owner. * @@ -464,6 +498,161 @@ private static JsonTypeUse requireOwner(JsonTypeUse owner, TypeRef ownerType) return owner; } + private static JsonCodec memberAnnotation(AnnotatedElement member, String source) { + try { + return member.getAnnotation(JsonCodec.class); + } catch (RuntimeException | LinkageError e) { + throw new ForyJsonException("Cannot read @JsonCodec from " + source, e); + } + } + + private static Object fieldTypeUse(Field field, String source) { + try { + return TypeUseMetadata.fieldTypeUse(field); + } catch (RuntimeException | LinkageError e) { + throw new ForyJsonException("Cannot read type-use @JsonCodec from " + source, e); + } + } + + private static Object methodReturnTypeUse(Method method, String source) { + try { + return TypeUseMetadata.methodReturnTypeUse(method); + } catch (RuntimeException | LinkageError e) { + throw new ForyJsonException("Cannot read type-use @JsonCodec from " + source, e); + } + } + + private static boolean hasNestedTypeShape(Type type) { + return type instanceof ParameterizedType + || type instanceof GenericArrayType + || type instanceof Class && ((Class) type).isArray(); + } + + private static JsonTypeUse declarationFirst( + Type type, JsonCodec declaration, Object typeUse, String source) { + JsonTypeUse reflected = fromTypeUse(typeUse, source); + return mergeDeclaration(type, declaration, reflected, source); + } + + static JsonTypeUse mergeDeclaration( + Type type, JsonCodec declaration, JsonTypeUse reflected, String source) { + if (declaration == null) { + return reflected; + } + JsonTypeUse root = rootTypeUse(type, declaration, source); + if (reflected == null || !reflected.hasExplicitDescendant()) { + return root; + } + JsonTypeUse descendants = + new JsonTypeUse( + reflected.type, + null, + NO_SOURCES, + reflected.arguments, + reflected.arrayComponent, + reflected.upperBounds, + reflected.lowerBounds); + return merge(root, descendants, source); + } + + private static JsonTypeUse rootTypeUse(Type type, JsonCodec annotation, String source) { + if (annotation == null) { + return null; + } + JsonTypeUse plain = plainType(type); + return new JsonTypeUse( + type, + readCodecClass(annotation, source, "$"), + new String[] {source + " at $"}, + plain.arguments, + plain.arrayComponent, + plain.upperBounds, + plain.lowerBounds); + } + + private static JsonTypeUse applyGenerated( + JsonTypeUse node, + Class> codecClass, + String source, + int[] path, + int pathIndex, + String canonicalPath) { + if (pathIndex == path.length) { + if (node.type instanceof WildcardType) { + throw new ForyJsonException( + "@JsonCodec cannot target a wildcard at " + source + " " + canonicalPath); + } + return new JsonTypeUse( + node.type, + codecClass, + new String[] {source + " at " + canonicalPath}, + node.arguments, + node.arrayComponent, + node.upperBounds, + node.lowerBounds); + } + int step = path[pathIndex]; + if (step == WILDCARD_UPPER_BOUND || step == WILDCARD_LOWER_BOUND) { + String bound = step == WILDCARD_UPPER_BOUND ? ".upperBound[0]" : ".lowerBound[0]"; + JsonTypeUse[] bounds = step == WILDCARD_UPPER_BOUND ? node.upperBounds : node.lowerBounds; + if (!(node.type instanceof WildcardType) || bounds.length == 0) { + throw invalidGeneratedPath(source, canonicalPath + bound, node.type); + } + throw new ForyJsonException( + "@JsonCodec cannot target a wildcard or wildcard bound at " + + source + + " " + + canonicalPath + + bound); + } + if (step == ARRAY_COMPONENT) { + if (node.arrayComponent == null) { + throw invalidGeneratedPath(source, canonicalPath + ".component", node.type); + } + JsonTypeUse component = + applyGenerated( + node.arrayComponent, + codecClass, + source, + path, + pathIndex + 1, + canonicalPath + ".component"); + return new JsonTypeUse( + node.type, + node.codecClass, + node.codecSources, + node.arguments, + component, + node.upperBounds, + node.lowerBounds); + } + if (step < 0 || step >= node.arguments.length) { + throw invalidGeneratedPath(source, canonicalPath + ".argument[" + step + "]", node.type); + } + JsonTypeUse[] arguments = node.arguments.clone(); + String childPath = canonicalPath + ".argument[" + step + "]"; + arguments[step] = + applyGenerated(arguments[step], codecClass, source, path, pathIndex + 1, childPath); + return new JsonTypeUse( + node.type, + node.codecClass, + node.codecSources, + arguments, + node.arrayComponent, + node.upperBounds, + node.lowerBounds); + } + + private static ForyJsonException invalidGeneratedPath(String source, String path, Type type) { + return new ForyJsonException( + "Generated @JsonCodec path " + + path + + " from " + + source + + " does not exist in " + + type.getTypeName()); + } + private static boolean scanExplicit(Object typeUse, String source, String path) { JsonCodec annotation = TypeUseMetadata.typeUseAnnotation(typeUse, JsonCodec.class); Object[] upperBounds = TypeUseMetadata.wildcardUpperBounds(typeUse); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java index bd47fa5c49..5d6fad5563 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java @@ -21,8 +21,12 @@ import java.io.File; import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Member; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.BigInteger; import java.net.InetAddress; @@ -102,6 +106,7 @@ import org.apache.fory.json.codec.SqlJsonCodecs; import org.apache.fory.json.codegen.JsonCodegen; import org.apache.fory.json.codegen.JsonJITContext; +import org.apache.fory.json.meta.GeneratedJsonCodecMeta; import org.apache.fory.json.meta.JsonFieldKind; import org.apache.fory.json.meta.JsonTypeUse; import org.apache.fory.platform.AndroidSupport; @@ -129,7 +134,7 @@ */ public final class JsonSharedRegistry { private static final int TYPE_CHECK_CACHE_LIMIT = 8192; - private static final boolean CODEC_ANNOTATIONS_ENABLED = !AndroidSupport.IS_ANDROID; + private static final String GENERATED_META_SUFFIX = "_ForyJsonCodecMeta"; private static final Comparator DECLARATION_ORDER = new Comparator() { @Override @@ -160,6 +165,8 @@ public int compare(DeclarationCandidate left, DeclarationCandidate right) { private final Set> typesWithoutCodecDeclaration; private final ConcurrentHashMap>, JsonValueCodec> annotationCodecs; + private final IdentityHashMap, Map> generatedTypeUses; + private final Set> typesWithoutGeneratedTypeUses; public JsonSharedRegistry(JsonConfig config) { this(config, null); @@ -181,6 +188,11 @@ public JsonSharedRegistry(JsonConfig config) { typesWithoutCodecDeclaration = Collections.newSetFromMap(new IdentityHashMap, Boolean>()); annotationCodecs = new ConcurrentHashMap<>(); + generatedTypeUses = AndroidSupport.IS_ANDROID ? new IdentityHashMap<>() : null; + typesWithoutGeneratedTypeUses = + AndroidSupport.IS_ANDROID + ? Collections.newSetFromMap(new IdentityHashMap, Boolean>()) + : null; boolean codegenEnabled = config.codegenEnabled(); codegen = codegenEnabled ? new JsonCodegen(config.getCodegenHash(), classLoader) : null; asyncCompilationEnabled = codegenEnabled && config.asyncCompilationEnabled(); @@ -370,7 +382,7 @@ JsonValueCodec customCodec(Class type) { } JsonCodecDeclaration codecDeclaration(Class targetType) { - if (!CODEC_ANNOTATIONS_ENABLED || targetType.isAnnotation()) { + if (targetType.isAnnotation()) { return null; } synchronized (codecDeclarations) { @@ -391,6 +403,154 @@ JsonCodecDeclaration codecDeclaration(Class targetType) { } } + Map generatedTypeUses(Class targetType) { + if (!AndroidSupport.IS_ANDROID) { + return Collections.emptyMap(); + } + synchronized (generatedTypeUses) { + Map cached = generatedTypeUses.get(targetType); + if (cached != null) { + return cached; + } + if (typesWithoutGeneratedTypeUses.contains(targetType)) { + return Collections.emptyMap(); + } + Map loaded = loadGeneratedTypeUses(targetType); + if (loaded == null) { + // Android cannot observe whether source type-use annotations existed, so an absent + // companion + // is a normal cached miss. Only a companion that is found but malformed can fail here. + typesWithoutGeneratedTypeUses.add(targetType); + return Collections.emptyMap(); + } + generatedTypeUses.put(targetType, loaded); + return loaded; + } + } + + private static Map loadGeneratedTypeUses(Class targetType) { + String companionName = targetType.getName() + GENERATED_META_SUFFIX; + Class companion; + try { + companion = Class.forName(companionName, true, targetType.getClassLoader()); + } catch (ClassNotFoundException e) { + return null; + } catch (LinkageError | RuntimeException e) { + throw generatedMetaFailure(targetType, "cannot load " + companionName, e); + } + Class providerType; + try { + providerType = companion.asSubclass(GeneratedJsonCodecMeta.class); + } catch (ClassCastException e) { + throw generatedMetaFailure( + targetType, companionName + " does not implement GeneratedJsonCodecMeta", e); + } + GeneratedJsonCodecMeta provider; + try { + provider = providerType.getConstructor().newInstance(); + } catch (InvocationTargetException e) { + throw generatedMetaFailure(targetType, companionName + " constructor failed", e.getCause()); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + throw generatedMetaFailure( + targetType, companionName + " must have an accessible public no-argument constructor", e); + } + Map typeUses; + try { + typeUses = provider.typeUses(); + } catch (LinkageError | RuntimeException e) { + throw generatedMetaFailure(targetType, companionName + ".typeUses() failed", e); + } + validateGeneratedTypeUses(targetType, typeUses); + return typeUses; + } + + private static void validateGeneratedTypeUses( + Class targetType, Map typeUses) { + if (typeUses == null || typeUses.isEmpty()) { + throw generatedMetaFailure(targetType, "typeUses() must return a non-empty map", null); + } + boolean hasGeneratedCodec = false; + for (Map.Entry entry : typeUses.entrySet()) { + Member member = entry.getKey(); + JsonTypeUse[] slots = entry.getValue(); + if (member == null || slots == null) { + throw generatedMetaFailure( + targetType, "typeUses() contains a null member or slot array", null); + } + Class declaringType = member.getDeclaringClass(); + if (!declaringType.isAssignableFrom(targetType)) { + throw generatedMemberFailure(targetType, member, "declaring type is outside the hierarchy"); + } + Type[] types; + if (member instanceof Field) { + if (slots.length != 1) { + throw generatedMemberFailure(targetType, member, "field metadata must have one slot"); + } + types = new Type[] {((Field) member).getGenericType()}; + } else if (member instanceof Method) { + Method method = (Method) member; + Type[] parameters = method.getGenericParameterTypes(); + types = new Type[parameters.length + 1]; + types[0] = method.getGenericReturnType(); + System.arraycopy(parameters, 0, types, 1, parameters.length); + } else if (member instanceof Constructor) { + if (declaringType != targetType) { + throw generatedMemberFailure( + targetType, member, "constructor must be declared by the target type"); + } + Type[] parameters = ((Constructor) member).getGenericParameterTypes(); + types = new Type[parameters.length + 1]; + System.arraycopy(parameters, 0, types, 1, parameters.length); + } else { + throw generatedMemberFailure(targetType, member, "unsupported member kind"); + } + if (slots.length != types.length) { + throw generatedMemberFailure( + targetType, member, "expected " + types.length + " slots but found " + slots.length); + } + for (int i = 0; i < slots.length; i++) { + JsonTypeUse typeUse = slots[i]; + if (typeUse == null) { + continue; + } + if (member instanceof Constructor && i == 0) { + throw generatedMemberFailure(targetType, member, "constructor return slot must be null"); + } + if (!typeUse.type().equals(types[i])) { + throw generatedMemberFailure( + targetType, + member, + "slot " + + i + + " type " + + typeUse.type().getTypeName() + + " does not match " + + types[i].getTypeName()); + } + if (!typeUse.hasExplicitCodec()) { + throw generatedMemberFailure( + targetType, member, "slot " + i + " contains no codec metadata"); + } + hasGeneratedCodec = true; + } + } + if (!hasGeneratedCodec) { + throw generatedMetaFailure(targetType, "typeUses() contains no codec metadata", null); + } + } + + private static ForyJsonException generatedMemberFailure( + Class targetType, Member member, String reason) { + return generatedMetaFailure(targetType, member + ": " + reason, null); + } + + private static ForyJsonException generatedMetaFailure( + Class targetType, String reason, Throwable cause) { + String message = + "Invalid generated JSON codec metadata for " + targetType.getName() + ": " + reason; + return cause == null ? new ForyJsonException(message) : new ForyJsonException(message, cause); + } + JsonValueCodec annotationCodec( Class targetType, Class> codecClass) { checkCustomSecure(targetType); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java index 040916cf2c..7fe5fdc1f4 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java @@ -1368,10 +1368,12 @@ private ObjectCodec buildObjectCodec( } private ObjectCodec newObjectCodec(TypeRef ownerType, JsonTypeUse ownerTypeUse) { - sharedRegistry.checkSecure(ownerType.getRawType()); + Class rawType = ownerType.getRawType(); + sharedRegistry.checkSecure(rawType); return ObjectCodec.build( ownerType, ownerTypeUse, + sharedRegistry.generatedTypeUses(rawType), sharedRegistry.propertyDiscoveryEnabled(), sharedRegistry.propertyNamingStrategy(), sharedRegistry.writeNullFields()); diff --git a/java/fory-json/src/main/java17/org/apache/fory/json/codec/ForyJsonGraalVMFeature.java b/java/fory-json/src/main/java17/org/apache/fory/json/codec/ForyJsonGraalVMFeature.java index ab169a7615..a9ce52ab81 100644 --- a/java/fory-json/src/main/java17/org/apache/fory/json/codec/ForyJsonGraalVMFeature.java +++ b/java/fory-json/src/main/java17/org/apache/fory/json/codec/ForyJsonGraalVMFeature.java @@ -44,6 +44,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.apache.fory.json.ForyJson; +import org.apache.fory.json.annotation.JsonAnyGetter; +import org.apache.fory.json.annotation.JsonAnyProperty; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; import org.apache.fory.json.annotation.JsonSubTypes; @@ -131,7 +133,11 @@ boolean record = type.isRecord(); if (!current.isRecord() && Runtime.version().feature() <= 24) { access.registerAsUnsafeAccessed(field); } - registerAnnotatedType(field.getAnnotatedType()); + if (field.isAnnotationPresent(JsonAnyProperty.class)) { + registerNestedType(field.getAnnotatedType()); + } else { + registerMemberType(field.getAnnotation(JsonCodec.class), field.getAnnotatedType()); + } registerResolvedType(ownerType.resolveType(field.getGenericType()).getType()); } } @@ -142,7 +148,12 @@ boolean record = type.isRecord(); RuntimeReflection.register(method); } if (ObjectCodecBuilder.usesJsonReturn(method)) { - registerAnnotatedType(method.getAnnotatedReturnType()); + if (method.isAnnotationPresent(JsonAnyGetter.class)) { + registerNestedType(method.getAnnotatedReturnType()); + } else { + registerMemberType( + method.getAnnotation(JsonCodec.class), method.getAnnotatedReturnType()); + } registerResolvedType(ownerType.resolveType(method.getGenericReturnType()).getType()); } if (ObjectCodecBuilder.usesJsonParameters(method)) { @@ -240,27 +251,43 @@ private void registerResolvedTypes(Type[] types, Set> visiting) private void registerAnnotatedType(AnnotatedType type) { Set> visiting = Collections.newSetFromMap(new IdentityHashMap<>()); - registerAnnotatedType(type, visiting); + registerAnnotatedType(type, true, visiting); } - private void registerAnnotatedType(AnnotatedType type, Set> visiting) { + private void registerMemberType(JsonCodec declaration, AnnotatedType type) { + if (declaration != null) { + registerCodec(declaration.value()); + } + Set> visiting = Collections.newSetFromMap(new IdentityHashMap<>()); + registerAnnotatedType(type, declaration == null, visiting); + } + + private void registerNestedType(AnnotatedType type) { + Set> visiting = Collections.newSetFromMap(new IdentityHashMap<>()); + registerAnnotatedType(type, false, visiting); + } + + private void registerAnnotatedType( + AnnotatedType type, boolean registerRootCodec, Set> visiting) { if (type == null) { return; } registerContainer(type.getType()); - JsonCodec annotation = type.getDeclaredAnnotation(JsonCodec.class); - if (annotation != null) { - registerCodec(annotation.value()); + if (registerRootCodec) { + JsonCodec annotation = type.getAnnotation(JsonCodec.class); + if (annotation != null) { + registerCodec(annotation.value()); + } } if (type instanceof AnnotatedParameterizedType) { AnnotatedParameterizedType parameterizedType = (AnnotatedParameterizedType) type; - registerAnnotatedType(parameterizedType.getAnnotatedOwnerType(), visiting); + registerAnnotatedType(parameterizedType.getAnnotatedOwnerType(), true, visiting); for (AnnotatedType argument : parameterizedType.getAnnotatedActualTypeArguments()) { - registerAnnotatedType(argument, visiting); + registerAnnotatedType(argument, true, visiting); } } else if (type instanceof AnnotatedArrayType) { registerAnnotatedType( - ((AnnotatedArrayType) type).getAnnotatedGenericComponentType(), visiting); + ((AnnotatedArrayType) type).getAnnotatedGenericComponentType(), true, visiting); } else if (type instanceof AnnotatedWildcardType) { AnnotatedWildcardType wildcardType = (AnnotatedWildcardType) type; registerAnnotatedTypes(wildcardType.getAnnotatedUpperBounds(), visiting); @@ -276,7 +303,7 @@ private void registerAnnotatedType(AnnotatedType type, Set> visi private void registerAnnotatedTypes(AnnotatedType[] types, Set> visiting) { for (AnnotatedType type : types) { - registerAnnotatedType(type, visiting); + registerAnnotatedType(type, true, visiting); } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java new file mode 100644 index 0000000000..bb8cff9e37 --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java @@ -0,0 +1,327 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.json; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.expectThrows; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.meta.GeneratedJsonCodecMeta; +import org.apache.fory.json.meta.JsonTypeUse; +import org.apache.fory.platform.AndroidSupport; +import org.apache.fory.platform.JdkVersion; +import org.testng.annotations.Test; + +public class JsonAndroidRuntimeTest { + @Test + public void generatedMap() { + GeneratedJsonCodecMeta meta = new AndroidModel_ForyJsonCodecMeta(); + Map first = meta.typeUses(); + assertSame(meta.typeUses(), first); + assertThrows( + UnsupportedOperationException.class, + () -> first.put(first.keySet().iterator().next(), new JsonTypeUse[0])); + } + + @Test + public void normalJvmIgnoresCompanion() throws Exception { + Field rootField = RootFieldModel.class.getField("value"); + Method rootGetter = RootGetterModel.class.getMethod("getValue"); + assertNull(rootField.getAnnotation(JsonCodec.class)); + assertNotNull(rootField.getAnnotatedType().getAnnotation(JsonCodec.class)); + assertNull(rootGetter.getAnnotation(JsonCodec.class)); + assertNotNull(rootGetter.getAnnotatedReturnType().getAnnotation(JsonCodec.class)); + + int constructions = AndroidModel_ForyJsonCodecMeta.constructions; + int calls = AndroidModel_ForyJsonCodecMeta.calls; + ForyJson json = ForyJson.builder().withCodegen(false).build(); + AndroidModel model = new AndroidModel(); + model.values = Collections.singletonList("plain"); + String text = json.toJson(model); + assertTrue(text.contains("\"plain\""), text); + assertTrue(text.contains("\"label\""), text); + assertFalse(text.contains("\"A:"), text); + assertEquals(AndroidModel_ForyJsonCodecMeta.constructions, constructions); + assertEquals(AndroidModel_ForyJsonCodecMeta.calls, calls); + } + + @Test + public void forcedAndroid() throws Exception { + ProcessBuilder processBuilder = + new ProcessBuilder(javaCommand(System.getProperty("java.class.path"), AndroidMain.class)) + .redirectErrorStream(true); + processBuilder.environment().put("FORY_ANDROID_ENABLED", "1"); + Process process = processBuilder.start(); + String output = readFully(process.getInputStream()); + assertEquals(process.waitFor(), 0, output); + assertTrue(output.contains("RESULT:ok"), output); + } + + private static List javaCommand(String classPath, Class mainClass) { + List command = + new ArrayList<>( + Arrays.asList( + System.getProperty("java.home") + + File.separator + + "bin" + + File.separator + + "java")); + if (JdkVersion.MAJOR_VERSION >= 25) { + command.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + } + command.add("-cp"); + command.add(classPath); + command.add(mainClass.getName()); + return command; + } + + private static String readFully(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } + + public static final class AndroidMain { + public static void main(String[] args) { + assertTrue(AndroidSupport.IS_ANDROID); + ForyJson json = ForyJson.builder().withCodegen(true).withAsyncCompilation(true).build(); + assertFalse(ForyJsonTestModels.hasGeneratedCapability(json, AndroidModel.class)); + + AndroidModel model = new AndroidModel(); + model.values = Arrays.asList("one", "two"); + String text = json.toJson(model); + assertTrue(text.contains("\"values\":[\"A:one\",\"A:two\"]"), text); + assertTrue(text.contains("\"inherited\":[\"A:one\",\"A:two\"]"), text); + assertTrue(text.contains("\"A:label\""), text); + AndroidModel decoded = json.fromJson("{\"values\":[\"A:three\"]}", AndroidModel.class); + assertEquals(decoded.values, Collections.singletonList("three")); + + DirectModel direct = new DirectModel(); + direct.value = "four"; + assertEquals(json.toJson(direct), "{\"value\":\"A:four\"}"); + assertEquals(json.fromJson("{\"value\":\"A:five\"}", DirectModel.class).value, "five"); + + GetterModel getter = new GetterModel(); + getter.setValue("six"); + assertEquals(json.toJson(getter), "{\"value\":\"A:six\"}"); + assertEquals(json.fromJson("{\"value\":\"A:seven\"}", GetterModel.class).getValue(), "seven"); + + RootFieldModel rootField = new RootFieldModel(); + rootField.value = "eight"; + assertEquals(json.toJson(rootField), "{\"value\":\"eight\"}"); + assertEquals(json.fromJson("{\"value\":\"nine\"}", RootFieldModel.class).value, "nine"); + + RootGetterModel rootGetter = new RootGetterModel(); + rootGetter.setValue("ten"); + assertEquals(json.toJson(rootGetter), "{\"value\":\"ten\"}"); + assertEquals( + json.fromJson("{\"value\":\"eleven\"}", RootGetterModel.class).getValue(), "eleven"); + + JsonCodecAnnotationTest.ParameterLocalCreator creator = + json.fromJson( + "{\"value\":\"A:creator\"}", JsonCodecAnnotationTest.ParameterLocalCreator.class); + assertEquals(creator.getValue(), "A:creator"); + assertEquals(json.toJson(creator), "{\"value\":\"A:creator\"}"); + + assertEquals(json.toJson(new JsonCodecAnnotationTest.DeclaredValue()), "\"declared-value\""); + assertTrue( + json.fromJson("\"declared-value\"", JsonCodecAnnotationTest.DeclaredValue.class) + instanceof JsonCodecAnnotationTest.DeclaredValue); + + MissingCompanion plain = new MissingCompanion(); + plain.values = Collections.singletonList("plain"); + assertEquals(json.toJson(plain), "{\"values\":[\"plain\"]}"); + + ForyJsonException malformed = + expectThrows(ForyJsonException.class, () -> json.toJson(new MalformedModel())); + assertTrue(malformed.getMessage().contains("declaring type is outside the hierarchy")); + assertEquals(AndroidModel_ForyJsonCodecMeta.constructions, 1); + assertEquals(AndroidModel_ForyJsonCodecMeta.calls, 1); + System.out.println("RESULT:ok"); + } + } + + public static class AndroidParent { + public List values; + + public List getInherited() { + return values; + } + } + + public interface AndroidLabels { + default List getLabels() { + return Collections.singletonList("label"); + } + } + + public static final class AndroidModel extends AndroidParent implements AndroidLabels {} + + public static final class AndroidModel_ForyJsonCodecMeta implements GeneratedJsonCodecMeta { + private static final Map TYPE_USES = buildTypeUses(); + private static int constructions; + private static int calls; + + public AndroidModel_ForyJsonCodecMeta() { + constructions++; + } + + @Override + public Map typeUses() { + calls++; + return TYPE_USES; + } + + private static Map buildTypeUses() { + try { + Field values = AndroidParent.class.getDeclaredField("values"); + Method inherited = AndroidParent.class.getDeclaredMethod("getInherited"); + Method labels = AndroidLabels.class.getMethod("getLabels"); + Map typeUses = new LinkedHashMap<>(); + typeUses.put( + values, + new JsonTypeUse[] { + JsonTypeUse.generated( + values.getGenericType(), + JsonCodecAnnotationTest.AStringCodec.class, + "field " + values, + 0) + }); + typeUses.put( + inherited, + new JsonTypeUse[] { + JsonTypeUse.generated( + inherited.getGenericReturnType(), + JsonCodecAnnotationTest.AStringCodec.class, + "method return " + inherited, + 0) + }); + typeUses.put( + labels, + new JsonTypeUse[] { + JsonTypeUse.generated( + labels.getGenericReturnType(), + JsonCodecAnnotationTest.AStringCodec.class, + "method return " + labels, + 0) + }); + return Collections.unmodifiableMap(typeUses); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + } + + public static final class DirectModel { + @JsonCodec(JsonCodecAnnotationTest.AStringCodec.class) + public String value; + } + + public static final class GetterModel { + private String value; + + @JsonCodec(JsonCodecAnnotationTest.AStringCodec.class) + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + public static final class RootFieldModel { + public java.lang.@JsonCodec(JsonCodecAnnotationTest.AStringCodec.class) String value; + } + + public static final class RootGetterModel { + private String value; + + public java.lang.@JsonCodec(JsonCodecAnnotationTest.AStringCodec.class) String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + public static final class MissingCompanion { + public List values; + } + + public static final class MalformedModel { + public List values; + } + + public static final class MalformedModel_ForyJsonCodecMeta implements GeneratedJsonCodecMeta { + private static final Map TYPE_USES = buildTypeUses(); + + public MalformedModel_ForyJsonCodecMeta() {} + + @Override + public Map typeUses() { + return TYPE_USES; + } + + private static Map buildTypeUses() { + try { + Field values = AndroidParent.class.getDeclaredField("values"); + Map typeUses = new LinkedHashMap<>(); + typeUses.put( + values, + new JsonTypeUse[] { + JsonTypeUse.generated( + values.getGenericType(), + JsonCodecAnnotationTest.AStringCodec.class, + "field " + values, + 0) + }); + return Collections.unmodifiableMap(typeUses); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + } +} diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java index a499c44307..81f1616e04 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java @@ -150,7 +150,22 @@ public void anyValues() { public void anyRejections() { assertFailure( () -> newJson().toJson(new OuterAny()), "cannot select the flattened JSON Any map"); + assertFailure( + () -> newJson().toJson(new MethodOuterAny()), + "@JsonCodec requires an effective ordinary JSON getter or record accessor"); assertFailure(() -> newJson().toJson(new KeyAny()), "map-key subtree"); + assertFailure( + () -> newJson().toJson(new StaticCodecField()), + "@JsonCodec is not supported on JSON field"); + } + + @Test + public void interfaceMethods() { + assertEquals(newJson().toJson(new InterfaceGetter()), "{\"value\":\"A:x\"}"); + assertEquals(newJson().toJson(new OverriddenGetter()), "{\"value\":\"x\"}"); + assertFailure( + () -> newJson().toJson(new UnrelatedMethod()), + "@JsonCodec requires an effective ordinary JSON getter or record accessor"); } @Test @@ -815,11 +830,47 @@ public static class OuterAny { public @JsonCodec(WholeMapCodec.class) Map values = new LinkedHashMap<>(); } + public static class MethodOuterAny { + @JsonAnyGetter + @JsonCodec(WholeMapCodec.class) + public Map values() { + return new LinkedHashMap<>(); + } + } + public static class KeyAny { @JsonAnyProperty public Map<@JsonCodec(AStringCodec.class) String, String> values = new LinkedHashMap<>(); } + public static class StaticCodecField { + @JsonCodec(AStringCodec.class) + public static String value; + } + + public interface CodecGetter { + @JsonCodec(AStringCodec.class) + default String getValue() { + return "x"; + } + } + + public static class InterfaceGetter implements CodecGetter {} + + public static class OverriddenGetter implements CodecGetter { + @Override + public String getValue() { + return "x"; + } + } + + public static class UnrelatedMethod { + @JsonCodec(AStringCodec.class) + public String value() { + return "x"; + } + } + public enum ScalarKind { ONE, TWO @@ -854,11 +905,11 @@ public static class WholeArray { } public static class ComponentArray { - public @JsonCodec(AStringCodec.class) String[] value; + public java.lang.@JsonCodec(AStringCodec.class) String[] value; } public static class MultiArray { - public @JsonCodec(AStringCodec.class) String[][] value; + public java.lang.@JsonCodec(AStringCodec.class) String[][] value; } public static class ListModel { diff --git a/java/fory-json/src/test/java/org/apache/fory/json/meta/JsonTypeUseTest.java b/java/fory-json/src/test/java/org/apache/fory/json/meta/JsonTypeUseTest.java index bb47986502..efe9871bd6 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/meta/JsonTypeUseTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/meta/JsonTypeUseTest.java @@ -111,23 +111,77 @@ public void testMergeAndWildcardRejection() throws Exception { assertThrows(ForyJsonException.class, () -> typeUse("wildcard")); } + @Test + public void generatedPaths() throws Exception { + Field values = Models.class.getDeclaredField("plainValues"); + JsonTypeUse list = + JsonTypeUse.generated(values.getGenericType(), ACodec.class, "plainValues", 0); + assertEquals(list.rawType(), List.class); + assertFalse(list.hasCodec()); + assertEquals(list.argument(0).codecClass(), ACodec.class); + assertTrue(list.argument(0).codecSource().contains("$.argument[0]")); + + JsonTypeUse root = JsonTypeUse.generated(values.getGenericType(), ACodec.class, "plainValues"); + assertEquals(root.codecClass(), ACodec.class); + assertTrue(root.codecSource().contains("at $")); + + Field array = Models.class.getDeclaredField("plainArray"); + JsonTypeUse component = + JsonTypeUse.generated( + array.getGenericType(), ACodec.class, "plainArray", JsonTypeUse.ARRAY_COMPONENT); + assertEquals(component.arrayComponent().codecClass(), ACodec.class); + + Field map = Models.class.getDeclaredField("plainMap"); + JsonTypeUse value = JsonTypeUse.generated(map.getGenericType(), ACodec.class, "plainMap", 1); + assertEquals(value.argument(1).codecClass(), ACodec.class); + + Field wildcard = Models.class.getDeclaredField("plainWildcard"); + assertThrows( + ForyJsonException.class, + () -> + JsonTypeUse.generated( + wildcard.getGenericType(), + ACodec.class, + "plainWildcard", + 0, + JsonTypeUse.WILDCARD_UPPER_BOUND)); + } + + @Test + public void declarationPrecedence() throws Exception { + Field field = Models.class.getDeclaredField("declaration"); + JsonTypeUse declaration = + JsonTypeUse.mergeDeclaration( + field.getGenericType(), + field.getAnnotation(JsonCodec.class), + JsonTypeUse.generated(field.getGenericType(), BCodec.class, "generated root"), + "declaration precedence"); + assertEquals(declaration.codecClass(), ACodec.class); + } + private static JsonTypeUse typeUse(String name) throws Exception { return JsonTypeUse.forField(Models.class.getDeclaredField(name)); } private static final class Models { + @JsonCodec(ACodec.class) + String declaration; + List<@JsonCodec(ACodec.class) String> values; List<@JsonCodec(ACodec.class) String> sameValues; List<@JsonCodec(BCodec.class) String> conflictingValues; String @JsonCodec(ACodec.class) [] array; - @JsonCodec(ACodec.class) - String[] elements; + java.lang.@JsonCodec(ACodec.class) String[] elements; Envelope<@JsonCodec(ACodec.class) String> envelope; ReorderedValues reorderedValues; ReorderedEntries<@JsonCodec(ACodec.class) Integer, String> reorderedEntries; List<@JsonCodec(ACodec.class) ? extends String> wildcard; + List plainValues; + String[] plainArray; + Map plainMap; + List plainWildcard; } private static final class Envelope { From cc0743d7e288cf635d9a148f6489d16ef5b001b9 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Wed, 15 Jul 2026 16:54:28 +0800 Subject: [PATCH 2/5] fix(java): align record JSON codec ownership --- docs/guide/java/json-support.md | 10 +++--- java/fory-json/README.md | 9 ++--- .../fory/json/annotation/JsonCodec.java | 10 +++--- .../fory/json/codec/ObjectCodecBuilder.java | 33 ++++++++++++++++--- .../fory/json/JsonCodecAnnotationTest.java | 18 ++++++++-- 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/guide/java/json-support.md b/docs/guide/java/json-support.md index 7167ad39c5..ceef6137ab 100644 --- a/docs/guide/java/json-support.md +++ b/docs/guide/java/json-support.md @@ -754,11 +754,11 @@ public final class Invoice { } ``` -The method form is also valid on an effective record accessor. It is invalid on setters, creator -factories, unrelated methods, and void methods. An ordinary getter declaration is invalid when -field mode disables getter discovery; record accessors continue to follow the existing record owner -path. For a selected field or getter, Fory checks the declaration first and uses the annotation on -the root annotated type only when the declaration is absent. +The method form is invalid on record accessors, setters, creator factories, unrelated methods, and +void methods. An ordinary getter declaration is invalid when field mode disables getter discovery. +Existing record-component `@JsonCodec` behavior is unchanged; declaring `@JsonCodec` only on a +record accessor is unsupported. For a selected field or getter, Fory checks the declaration first +and uses the annotation on the root annotated type only when the declaration is absent. Use the same annotation at a type-use position to select a codec for exactly that occurrence: diff --git a/java/fory-json/README.md b/java/fory-json/README.md index c3e23eb114..0b79122d54 100644 --- a/java/fory-json/README.md +++ b/java/fory-json/README.md @@ -877,10 +877,11 @@ public final class Invoice { } ``` -The method form is also valid on an effective record accessor. It is invalid on setters, creator -factories, unrelated methods, and void methods. An ordinary getter declaration is invalid when -field mode disables getter discovery; record accessors continue to follow the existing record owner -path. Fory checks the field or method declaration before falling back to the root annotated type. +The method form is invalid on record accessors, setters, creator factories, unrelated methods, and +void methods. An ordinary getter declaration is invalid when field mode disables getter discovery. +Existing record-component `@JsonCodec` behavior is unchanged; declaring `@JsonCodec` only on a +record accessor is unsupported. Fory checks the field or method declaration before falling back to +the root annotated type. Use `@JsonCodec` at a type-use position to change one exact occurrence while retaining the normal mapping around it: diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java index 714cfaf13d..d2b5457afc 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonCodec.java @@ -39,11 +39,11 @@ * such a hierarchy. * *

On a field, this annotation selects the codec for the field's root value. On a method, it is - * valid only on an effective ordinary JSON getter or record accessor and selects the codec for the - * return value. A {@link org.apache.fory.json.annotation.JsonAnyProperty JsonAnyProperty} field and - * a {@link org.apache.fory.json.annotation.JsonAnyGetter JsonAnyGetter} are flattened into their - * owning object and therefore cannot have a complete root codec, although their nested map values - * may have type-use codecs. A declaration annotation takes precedence over an annotation on the + * valid only on an effective ordinary JSON getter and selects the codec for the return value. A + * {@link org.apache.fory.json.annotation.JsonAnyProperty JsonAnyProperty} field and a {@link + * org.apache.fory.json.annotation.JsonAnyGetter JsonAnyGetter} are flattened into their owning + * object and therefore cannot have a complete root codec, although their nested map values may have + * type-use codecs. A declaration annotation takes precedence over an annotation on the * corresponding root type use. * *

On a type use, this annotation applies only to that resolved value node. It can select the diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java index 9803cbf0b2..a320fa015e 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java @@ -1084,11 +1084,36 @@ private static boolean validateMemberAnnotations( private static void validateCodecMethod( Class type, Method method, boolean propertyDiscoveryEnabled, boolean record) { - boolean readable = - record && isRecordAccessor(type, method) || !record && getterPropertyName(method) != null; - if ((!record && !propertyDiscoveryEnabled) || !isEligibleAccessor(method) || !readable) { + if (record) { + // javac copies a record-component annotation to every applicable generated member. The + // component/backing field remains the existing codec owner; tolerate only that identical + // duplicate so an explicit accessor-local declaration cannot be accepted and then ignored. + if (isPropagatedRecordCodec(type, method)) { + return; + } + throw new ForyJsonException( + "@JsonCodec requires an effective ordinary JSON getter: " + method); + } + if (!propertyDiscoveryEnabled + || !isEligibleAccessor(method) + || getterPropertyName(method) == null) { throw new ForyJsonException( - "@JsonCodec requires an effective ordinary JSON getter or record accessor: " + method); + "@JsonCodec requires an effective ordinary JSON getter: " + method); + } + } + + private static boolean isPropagatedRecordCodec(Class type, Method method) { + if (!isRecordAccessor(type, method)) { + return false; + } + try { + JsonCodec fieldCodec = type.getDeclaredField(method.getName()).getAnnotation(JsonCodec.class); + JsonCodec methodCodec = method.getAnnotation(JsonCodec.class); + return fieldCodec != null && methodCodec != null && fieldCodec.value() == methodCodec.value(); + } catch (NoSuchFieldException e) { + return false; + } catch (RuntimeException | LinkageError e) { + throw new ForyJsonException("Cannot read record-component @JsonCodec for " + method, e); } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java index 81f1616e04..c3a0087202 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonCodecAnnotationTest.java @@ -130,6 +130,20 @@ public void recordComponent() throws Exception { assertEquals(jsonText(newJson(), value), "{\"value\":\"A:x\"}"); Object decoded = newJson().fromJson("{\"value\":\"A:y\"}", type); assertEquals(type.getMethod("value").invoke(decoded), "y"); + + Class accessorType = + compileRecordClass( + "JsonCodecAccessorRecord", + "package org.apache.fory.json.records;\n" + + "import org.apache.fory.json.annotation.JsonCodec;\n" + + "import org.apache.fory.json.JsonCodecAnnotationTest.AStringCodec;\n" + + "public record JsonCodecAccessorRecord(String value) {\n" + + " @JsonCodec(AStringCodec.class) public String value() { return value; }\n" + + "}\n"); + Object accessorValue = accessorType.getConstructor(String.class).newInstance("x"); + assertFailure( + () -> jsonText(newJson(), accessorValue), + "@JsonCodec requires an effective ordinary JSON getter"); } @Test @@ -152,7 +166,7 @@ public void anyRejections() { () -> newJson().toJson(new OuterAny()), "cannot select the flattened JSON Any map"); assertFailure( () -> newJson().toJson(new MethodOuterAny()), - "@JsonCodec requires an effective ordinary JSON getter or record accessor"); + "@JsonCodec requires an effective ordinary JSON getter"); assertFailure(() -> newJson().toJson(new KeyAny()), "map-key subtree"); assertFailure( () -> newJson().toJson(new StaticCodecField()), @@ -165,7 +179,7 @@ public void interfaceMethods() { assertEquals(newJson().toJson(new OverriddenGetter()), "{\"value\":\"x\"}"); assertFailure( () -> newJson().toJson(new UnrelatedMethod()), - "@JsonCodec requires an effective ordinary JSON getter or record accessor"); + "@JsonCodec requires an effective ordinary JSON getter"); } @Test From b0665831581ee1dac3247202051c0775645dc39f Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Wed, 15 Jul 2026 17:14:07 +0800 Subject: [PATCH 3/5] fix(java): tighten generated JSON metadata --- .../processing/ForyStructProcessor.java | 43 ++++++------- .../processing/JavacTypeUseTrees.java | 27 +++++++-- .../processing/JsonTypeProcessor.java | 39 ++++-------- .../processing/JsonTypeProcessorTest.java | 60 +++++++++++++++---- .../json/resolver/JsonSharedRegistry.java | 3 +- 5 files changed, 106 insertions(+), 66 deletions(-) diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java index 162552301e..4fc0dfe0eb 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/ForyStructProcessor.java @@ -441,7 +441,7 @@ private SourceField buildField( Set modifiers = field.getModifiers(); ForyFieldMeta foryField = foryField(field); Object fieldTypeTree = typeTree(field); - boolean nullable = fieldNullable(field.asType(), fieldTypeTree, mode); + boolean nullable = fieldNullable(field.asType(), fieldTypeTree, mode, field); boolean trackingRef = fieldTrackingRef(field, fieldTypeTree); boolean hasTrackingRefMetadata = fieldHasTrackingRefMetadata(field, fieldTypeTree); SourceTypeNode typeNode = buildFieldTypeNode(field.asType(), fieldTypeTree, nullable, field); @@ -510,11 +510,12 @@ && isAccessibleFromGenerated(field, generatedPackage)) { foryField.dynamic); } - private boolean fieldNullable(TypeMirror type, Object tree, SerializerMode mode) { + private boolean fieldNullable( + TypeMirror type, Object tree, SerializerMode mode, VariableElement field) { if (type.getKind().isPrimitive()) { return false; } - if (typeUseAnnotation(type, typeUseTrees.tree(tree).annotations, NULLABLE) != null) { + if (typeUseAnnotation(type, typeUseTrees.tree(tree).annotations, NULLABLE, field) != null) { return true; } if (mode == SerializerMode.NATIVE) { @@ -525,7 +526,7 @@ private boolean fieldNullable(TypeMirror type, Object tree, SerializerMode mode) private boolean fieldTrackingRef(VariableElement field, Object tree) { TypeUseAnnotation ref = - typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF); + typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF, field); if (ref == null) { AnnotationMirror fieldRef = annotationMirror(field, REF); ref = fieldRef == null ? null : new TypeUseAnnotation(fieldRef, null); @@ -537,7 +538,8 @@ private boolean fieldHasTrackingRefMetadata(VariableElement field, Object tree) if (hasAnnotation(field, REF)) { return true; } - return typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF) != null; + return typeUseAnnotation(field.asType(), typeUseTrees.tree(tree).annotations, REF, field) + != null; } private boolean isOptionalType(TypeMirror type) { @@ -860,8 +862,9 @@ private String typeExtMetaExpression( Element errorElement, boolean arrayComponent) { String typeId = scalarTypeId(type, rawType, treeAnnotations, errorElement, arrayComponent); - TypeUseAnnotation nullableAnnotation = typeUseAnnotation(type, treeAnnotations, NULLABLE); - TypeUseAnnotation ref = typeUseAnnotation(type, treeAnnotations, REF); + TypeUseAnnotation nullableAnnotation = + typeUseAnnotation(type, treeAnnotations, NULLABLE, errorElement); + TypeUseAnnotation ref = typeUseAnnotation(type, treeAnnotations, REF, errorElement); if (typeId == null && nullableAnnotation == null && ref == null) { return null; } @@ -884,7 +887,7 @@ private String scalarTypeId( List treeAnnotations, Element errorElement, boolean arrayComponent) { - if (hasTypeAnnotation(type, treeAnnotations, INT8_TYPE)) { + if (hasTypeAnnotation(type, treeAnnotations, INT8_TYPE, errorElement)) { validateScalarCarrier( "@Int8Type", rawType, @@ -895,7 +898,7 @@ private String scalarTypeId( collectionClass("Int8List")); return rawType.equals("byte[]") ? "Types.INT8_ARRAY" : "Types.INT8"; } - if (hasTypeAnnotation(type, treeAnnotations, UINT8_TYPE)) { + if (hasTypeAnnotation(type, treeAnnotations, UINT8_TYPE, errorElement)) { validateScalarCarrier( "@UInt8Type", rawType, @@ -905,7 +908,7 @@ private String scalarTypeId( : new String[] {"int", "java.lang.Integer", "byte[]", collectionClass("UInt8List")}); return rawType.equals("byte[]") ? "Types.UINT8_ARRAY" : "Types.UINT8"; } - if (hasTypeAnnotation(type, treeAnnotations, UINT16_TYPE)) { + if (hasTypeAnnotation(type, treeAnnotations, UINT16_TYPE, errorElement)) { validateScalarCarrier( "@UInt16Type", rawType, @@ -917,7 +920,7 @@ private String scalarTypeId( }); return rawType.equals("short[]") ? "Types.UINT16_ARRAY" : "Types.UINT16"; } - TypeUseAnnotation uint32 = typeUseAnnotation(type, treeAnnotations, UINT32_TYPE); + TypeUseAnnotation uint32 = typeUseAnnotation(type, treeAnnotations, UINT32_TYPE, errorElement); if (uint32 != null) { validateScalarCarrier( "@UInt32Type", @@ -932,7 +935,7 @@ private String scalarTypeId( } return "FIXED".equals(encoding) ? "Types.UINT32" : "Types.VAR_UINT32"; } - TypeUseAnnotation uint64 = typeUseAnnotation(type, treeAnnotations, UINT64_TYPE); + TypeUseAnnotation uint64 = typeUseAnnotation(type, treeAnnotations, UINT64_TYPE, errorElement); if (uint64 != null) { validateScalarCarrier( "@UInt64Type", @@ -950,7 +953,7 @@ private String scalarTypeId( } return "TAGGED".equals(encoding) ? "Types.TAGGED_UINT64" : "Types.VAR_UINT64"; } - TypeUseAnnotation int32 = typeUseAnnotation(type, treeAnnotations, INT32_TYPE); + TypeUseAnnotation int32 = typeUseAnnotation(type, treeAnnotations, INT32_TYPE, errorElement); if (int32 != null) { validateScalarCarrier( "@Int32Type", @@ -962,7 +965,7 @@ private String scalarTypeId( String encoding = int32Encoding(int32); return "FIXED".equals(encoding) ? "Types.INT32" : "Types.VARINT32"; } - TypeUseAnnotation int64 = typeUseAnnotation(type, treeAnnotations, INT64_TYPE); + TypeUseAnnotation int64 = typeUseAnnotation(type, treeAnnotations, INT64_TYPE, errorElement); if (int64 != null) { validateScalarCarrier( "@Int64Type", @@ -977,7 +980,7 @@ private String scalarTypeId( } return "TAGGED".equals(encoding) ? "Types.TAGGED_INT64" : "Types.VARINT64"; } - if (hasTypeAnnotation(type, treeAnnotations, FLOAT16_TYPE)) { + if (hasTypeAnnotation(type, treeAnnotations, FLOAT16_TYPE, errorElement)) { validateScalarCarrier( "@Float16Type", rawType, @@ -985,7 +988,7 @@ private String scalarTypeId( arrayComponent ? new String[] {"short"} : new String[] {"short[]"}); return "Types.FLOAT16_ARRAY"; } - if (hasTypeAnnotation(type, treeAnnotations, BFLOAT16_TYPE)) { + if (hasTypeAnnotation(type, treeAnnotations, BFLOAT16_TYPE, errorElement)) { validateScalarCarrier( "@BFloat16Type", rawType, @@ -1008,18 +1011,18 @@ private void validateScalarCarrier( } private boolean hasTypeAnnotation( - TypeMirror type, List treeAnnotations, String annotationName) { - return typeUseAnnotation(type, treeAnnotations, annotationName) != null; + TypeMirror type, List treeAnnotations, String annotationName, Element source) { + return typeUseAnnotation(type, treeAnnotations, annotationName, source) != null; } private TypeUseAnnotation typeUseAnnotation( - TypeMirror type, List treeAnnotations, String annotationName) { + TypeMirror type, List treeAnnotations, String annotationName, Element source) { AnnotationMirror mirror = typeAnnotationMirror(type, annotationName); if (mirror != null) { return new TypeUseAnnotation(mirror, null); } for (Object annotationTree : treeAnnotations) { - if (typeUseTrees.isAnnotation(annotationTree, annotationName)) { + if (typeUseTrees.isAnnotation(source, annotationTree, annotationName)) { return new TypeUseAnnotation(null, annotationTree); } } diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java index 3e1ed0ca9e..c28e61c977 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JavacTypeUseTrees.java @@ -24,13 +24,17 @@ import java.util.List; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Types; /** Java 8-compatible access to javac type-use trees omitted from some {@code TypeMirror}s. */ final class JavacTypeUseTrees { private final Object trees; + private final Types types; JavacTypeUseTrees(ProcessingEnvironment processingEnv) { + types = processingEnv.getTypeUtils(); Object javacTrees; try { ClassLoader javacLoader = processingEnv.getClass().getClassLoader(); @@ -75,15 +79,19 @@ Tree tree(Object tree) { return new Tree(annotations, current); } - boolean isAnnotation(Object annotationTree, String annotationName) { + boolean isAnnotation(Element owner, Object annotationTree, String annotationName) { Object annotationType = invoke(annotationTree, "getAnnotationType"); if (annotationType == null) { return false; } - String treeName = annotationType.toString(); - int separator = annotationName.lastIndexOf('.'); - String simpleName = separator < 0 ? annotationName : annotationName.substring(separator + 1); - return treeName.equals(annotationName) || treeName.equals(simpleName); + TypeMirror mirror = treeType(owner, annotationType); + Element element = mirror == null ? null : types.asElement(mirror); + if (element instanceof TypeElement) { + return ((TypeElement) element).getQualifiedName().contentEquals(annotationName); + } + // A fully qualified tree remains unambiguous if javac cannot provide a symbol. Never match a + // simple name here: an imported third-party annotation may use the same name as a Fory one. + return annotationType.toString().equals(annotationName); } String annotationValue(Object annotationTree, String name, Object defaultValue) { @@ -122,6 +130,13 @@ TypeMirror annotationClassValue(Element owner, Object annotationTree, String nam && "class".equals(String.valueOf(invoke(valueTree, "getIdentifier")))) { valueTree = invoke(valueTree, "getExpression"); } + return treeType(owner, valueTree); + } + + private TypeMirror treeType(Element owner, Object tree) { + if (trees == null || owner == null || tree == null) { + return null; + } Object ownerPath = invoke(trees, "getPath", new Class[] {Element.class}, owner); Object compilationUnit = invoke(ownerPath, "getCompilationUnit"); if (compilationUnit == null) { @@ -138,7 +153,7 @@ TypeMirror annotationClassValue(Element owner, Object annotationTree, String nam Object valuePath = treePathClass .getMethod("getPath", compilationUnitClass, treeClass) - .invoke(null, compilationUnit, valueTree); + .invoke(null, compilationUnit, tree); Object type = invoke(trees, "getTypeMirror", new Class[] {treePathClass}, valuePath); return type instanceof TypeMirror ? (TypeMirror) type : null; } catch (ReflectiveOperationException | LinkageError e) { diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java index 7c76450806..7edb964237 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java @@ -70,7 +70,6 @@ final class JsonTypeProcessor { private static final String JSON_ANY_SETTER = JSON_PACKAGE + ".annotation.JsonAnySetter"; private static final String GENERATED_META = JSON_PACKAGE + ".meta.GeneratedJsonCodecMeta"; private static final String JSON_TYPE_USE = JSON_PACKAGE + ".meta.JsonTypeUse"; - private static final String JSON_VALUE_CODEC = JSON_PACKAGE + ".codec.JsonValueCodec"; private static final String META_SUFFIX = "_ForyJsonCodecMeta"; private static final String R8_PREFIX = "META-INF/com.android.tools/r8/fory-json-"; @@ -379,7 +378,7 @@ private void collectCodecNode( List codecs) { TypeElement codec = codecClass(type, tree, source); if (codec != null) { - codecs.add(codecPath(codec, path, packageName, model)); + codecs.add(codecPath(codec, path, packageName, model, source)); } collectNestedCodecs(type, tree, source, packageName, model, path, codecs); } @@ -398,7 +397,7 @@ private TypeElement codecClass(TypeMirror type, Object tree, Element source) { throw new InvalidJsonTypeException("Cannot resolve @JsonCodec value", source); } for (Object annotationTree : typeUseTrees.tree(tree).annotations) { - if (!typeUseTrees.isAnnotation(annotationTree, JSON_CODEC)) { + if (!typeUseTrees.isAnnotation(source, annotationTree, JSON_CODEC)) { continue; } TypeMirror codecType = typeUseTrees.annotationClassValue(source, annotationTree, "value"); @@ -412,22 +411,18 @@ private TypeElement codecClass(TypeMirror type, Object tree, Element source) { } private CodecPath codecPath( - TypeElement codec, List path, String packageName, Model model) { + TypeElement codec, List path, String packageName, Model model, Element source) { String binaryName = elements.getBinaryName(codec).toString(); + if (!isSourceAccessible(codec, packageName)) { + throw new InvalidJsonTypeException( + "@JsonCodec class " + + binaryName + + " is not accessible from generated metadata; use a public codec enclosed only by public classes", + source); + } model.codecTypes.add(binaryName); model.annotationTypes.add(JSON_CODEC); - String expression; - if (isSourceAccessible(codec, packageName)) { - // R8 rewrites class literals when it obfuscates an accessible codec. A binary-name lookup - // cannot be rewritten, so only that reflection fallback requires preserving the exact name. - expression = codec.getQualifiedName() + ".class"; - } else { - expression = "codecClass(\"" + escape(binaryName) + "\")"; - model.binaryFallbackTypes.add(binaryName); - model.needsClassLookup = true; - model.needsCodecLookup = true; - } - return new CodecPath(binaryName, expression, path); + return new CodecPath(binaryName, codec.getQualifiedName() + ".class", path); } private void collectCodecAnnotation(AnnotationMirror annotation, Model model) { @@ -481,9 +476,6 @@ private String writeMeta(Model model) { builder.append("import java.util.Map;\n"); builder.append("import ").append(GENERATED_META).append(";\n"); builder.append("import ").append(JSON_TYPE_USE).append(";\n"); - if (model.needsCodecLookup) { - builder.append("import ").append(JSON_VALUE_CODEC).append(";\n"); - } builder.append('\n'); builder .append("public final class ") @@ -559,14 +551,6 @@ private String writeMeta(Model model) { .append(".class.getClassLoader());\n"); builder.append(" }\n"); } - if (model.needsCodecLookup) { - builder.append("\n @SuppressWarnings({\"rawtypes\", \"unchecked\"})\n"); - builder - .append(" private static Class> codecClass(String name) ") - .append("throws ClassNotFoundException {\n"); - builder.append(" return (Class) classForName(name);\n"); - builder.append(" }\n"); - } return builder.append("}\n").toString(); } @@ -1171,7 +1155,6 @@ private static final class Model { final Set binaryFallbackTypes = new LinkedHashSet<>(); final Set annotationOwnerTypes = new LinkedHashSet<>(); boolean needsClassLookup; - boolean needsCodecLookup; Model(TypeElement target, String packageName, String binaryName, String metaSimpleName) { this.target = target; diff --git a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java index 4fe2c15e06..661853e389 100644 --- a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java +++ b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java @@ -102,7 +102,6 @@ public void testNestedMetadata() throws Exception { assertFalse(source.contains("getSuppressedValues"), source); assertFalse(source.contains("SuppressedCodec"), source); assertTrue(source.contains("test.Outer_Name.Codec.class"), source); - assertFalse(source.contains("codecClass("), source); assertFalse(source.contains("classForName("), source); assertFalse(source.contains("import java.lang.reflect.*"), source); assertFalse(source.contains("import java.util.*"), source); @@ -207,7 +206,7 @@ public void testDeclarationCodecRules() throws Exception { } @Test - public void testInaccessibleCodecFallback() throws Exception { + public void testInaccessibleCodec() throws Exception { CompilationResult result = compile( "test.HiddenCodecModel", @@ -219,18 +218,59 @@ public void testInaccessibleCodecFallback() throws Exception { + codecSource() .replace("public static final class Codec", "private static final class Codec") + "}\n"); + assertFalse(result.success, result.diagnostics()); + assertTrue(result.diagnostics().contains("is not accessible from generated metadata")); + } + + @Test + public void testInaccessibleMember() throws Exception { + CompilationResult result = + compile( + "test.HiddenMemberModel", + "package test;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "public class HiddenMemberModel {\n" + + " private static final class Hidden {}\n" + + " @JsonType public static class Model {\n" + + " public void setValue(@JsonCodec(Codec.class) Hidden value) {}\n" + + " }\n" + + codecSource() + + "}\n"); assertTrue(result.success, result.diagnostics()); - String source = result.generatedSource("test/HiddenCodecModel_ForyJsonCodecMeta.java"); - assertTrue(source.contains("codecClass(\"test.HiddenCodecModel$Codec\")"), source); - assertTrue(source.contains("classForName("), source); + String source = result.generatedSource("test/HiddenMemberModel$Model_ForyJsonCodecMeta.java"); + assertTrue(source.contains("classForName(\"test.HiddenMemberModel$Hidden\")"), source); + assertTrue(source.contains("test.HiddenMemberModel.Codec.class"), source); String rules = result.generatedResource( - "META-INF/com.android.tools/r8/fory-json-test.HiddenCodecModel.pro"); + "META-INF/com.android.tools/r8/fory-json-test.HiddenMemberModel$Model.pro"); assertTrue( - rules.contains( - "-keep,allowoptimization class test.HiddenCodecModel$Codec { public (); }"), - rules); - assertFalse(rules.contains("allowobfuscation class test.HiddenCodecModel$Codec"), rules); + rules.contains("-keep,allowoptimization class test.HiddenMemberModel$Hidden"), rules); + } + + @Test + public void testUnrelatedAnnotation() throws Exception { + Map sources = new LinkedHashMap<>(); + sources.put( + "test.other.JsonCodec", + "package test.other;\n" + + "import java.lang.annotation.*;\n" + + "@Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME)\n" + + "public @interface JsonCodec {}\n"); + sources.put( + "test.CollisionModel", + "package test;\n" + + "import java.util.List;\n" + + "import org.apache.fory.json.annotation.JsonType;\n" + + "import test.other.JsonCodec;\n" + + "@JsonType public class CollisionModel {\n" + + " public List<@JsonCodec String> values;\n" + + "}\n"); + CompilationResult result = compile(sources); + assertTrue(result.success, result.diagnostics()); + assertFalse(result.hasGeneratedSource("test/CollisionModel_ForyJsonCodecMeta.java")); + String rules = + result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.CollisionModel.pro"); + assertFalse(rules.contains("test.other.JsonCodec"), rules); } @Test diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java index 5d6fad5563..f336366672 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java @@ -418,8 +418,7 @@ Map generatedTypeUses(Class targetType) { Map loaded = loadGeneratedTypeUses(targetType); if (loaded == null) { // Android cannot observe whether source type-use annotations existed, so an absent - // companion - // is a normal cached miss. Only a companion that is found but malformed can fail here. + // companion is a normal cached miss. Only a found but malformed companion can fail here. typesWithoutGeneratedTypeUses.add(targetType); return Collections.emptyMap(); } From 30c11ba96d14549fe6d6853d040d1059c66a51b0 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Wed, 15 Jul 2026 17:33:47 +0800 Subject: [PATCH 4/5] fix(ci): run Android API condition in one shell --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 840d212ac9..fe1ada049c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,9 +329,7 @@ jobs: working-directory: integration_tests/android_tests script: | yes | sdkmanager "platforms;android-${{ matrix.api-level }}" "build-tools;35.0.0" - if [[ "${{ matrix.api-level }}" == "26" ]]; then - gradle --no-daemon --stacktrace -PforyTestBuildType=debug connectedCheck - fi + if [ "${{ matrix.api-level }}" = "26" ]; then gradle --no-daemon --stacktrace -PforyTestBuildType=debug connectedCheck; fi gradle --no-daemon --stacktrace -PforyTestBuildType=release connectedCheck - name: Upload Android Test Report uses: actions/upload-artifact@v4 From 496b9b23929b64acef2d038a9e439ea7ab6bb923 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Wed, 15 Jul 2026 18:46:51 +0800 Subject: [PATCH 5/5] fix(java): correct Android reflection and R8 metadata --- .../processing/JsonTypeProcessor.java | 2 +- .../processing/JsonTypeProcessorTest.java | 39 +++++++------------ .../apache/fory/reflect/TypeUseMetadata.java | 10 ++++- .../serializer/AndroidJvmRoundTripTest.java | 9 ++++- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java index 7edb964237..85d324ae0b 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java @@ -71,7 +71,7 @@ final class JsonTypeProcessor { private static final String GENERATED_META = JSON_PACKAGE + ".meta.GeneratedJsonCodecMeta"; private static final String JSON_TYPE_USE = JSON_PACKAGE + ".meta.JsonTypeUse"; private static final String META_SUFFIX = "_ForyJsonCodecMeta"; - private static final String R8_PREFIX = "META-INF/com.android.tools/r8/fory-json-"; + private static final String R8_PREFIX = "META-INF/proguard/fory-json-"; private final Filer filer; private final Messager messager; diff --git a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java index 661853e389..95387f94b7 100644 --- a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java +++ b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java @@ -55,8 +55,7 @@ public void testUnannotatedType() throws Exception { compile("test.Plain", "package test; public class Plain { int id; }"); assertTrue(result.success, result.diagnostics()); assertFalse(result.hasGeneratedSource("test/Plain_ForyJsonCodecMeta.java")); - assertFalse( - result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Plain.pro")); + assertFalse(result.hasGeneratedResource("META-INF/proguard/fory-json-test.Plain.pro")); } @Test @@ -74,8 +73,7 @@ public void testPlainJsonType() throws Exception { + "}\n"); assertTrue(result.success, result.diagnostics()); assertFalse(result.hasGeneratedSource("test/Plain_ForyJsonCodecMeta.java")); - String rules = - result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Plain.pro"); + String rules = result.generatedResource("META-INF/proguard/fory-json-test.Plain.pro"); assertTrue(rules.contains("-keepattributes Signature,RuntimeVisibleAnnotations"), rules); assertTrue(rules.contains("-keepattributes MethodParameters"), rules); assertTrue(rules.contains("int id;"), rules); @@ -128,7 +126,7 @@ public void testNestedMetadata() throws Exception { String rules = result.generatedResource( - "META-INF/com.android.tools/r8/fory-json-test.Outer_Name$Model_With_Underscore.pro"); + "META-INF/proguard/fory-json-test.Outer_Name$Model_With_Underscore.pro"); assertTrue( rules.contains( "-keep,allowoptimization class test.Outer_Name$Model_With_Underscore_ForyJsonCodecMeta"), @@ -173,12 +171,9 @@ public void testSubtypeAndFallback() throws Exception { + "}) public abstract class Base {}\n" + "class Child extends Base { public int value; }\n"); assertTrue(result.success, result.diagnostics()); - assertTrue( - result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Base.pro")); - assertTrue( - result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Child.pro")); - String rules = - result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Base.pro"); + assertTrue(result.hasGeneratedResource("META-INF/proguard/fory-json-test.Base.pro")); + assertTrue(result.hasGeneratedResource("META-INF/proguard/fory-json-test.Child.pro")); + String rules = result.generatedResource("META-INF/proguard/fory-json-test.Base.pro"); assertTrue(rules.contains("-keep,allowoptimization class external.HiddenChild"), rules); } @@ -198,8 +193,7 @@ public void testDeclarationCodecRules() throws Exception { assertTrue(result.success, result.diagnostics()); assertFalse(result.hasGeneratedSource("test/DeclarationModel_ForyJsonCodecMeta.java")); String rules = - result.generatedResource( - "META-INF/com.android.tools/r8/fory-json-test.DeclarationModel.pro"); + result.generatedResource("META-INF/proguard/fory-json-test.DeclarationModel.pro"); assertTrue(rules.contains("allowobfuscation class test.DeclarationModel$Codec"), rules); assertTrue(rules.contains("public ();"), rules); assertTrue(rules.contains("-keepattributes InnerClasses,EnclosingMethod"), rules); @@ -241,8 +235,7 @@ public void testInaccessibleMember() throws Exception { assertTrue(source.contains("classForName(\"test.HiddenMemberModel$Hidden\")"), source); assertTrue(source.contains("test.HiddenMemberModel.Codec.class"), source); String rules = - result.generatedResource( - "META-INF/com.android.tools/r8/fory-json-test.HiddenMemberModel$Model.pro"); + result.generatedResource("META-INF/proguard/fory-json-test.HiddenMemberModel$Model.pro"); assertTrue( rules.contains("-keep,allowoptimization class test.HiddenMemberModel$Hidden"), rules); } @@ -268,8 +261,7 @@ public void testUnrelatedAnnotation() throws Exception { CompilationResult result = compile(sources); assertTrue(result.success, result.diagnostics()); assertFalse(result.hasGeneratedSource("test/CollisionModel_ForyJsonCodecMeta.java")); - String rules = - result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.CollisionModel.pro"); + String rules = result.generatedResource("META-INF/proguard/fory-json-test.CollisionModel.pro"); assertFalse(rules.contains("test.other.JsonCodec"), rules); } @@ -286,8 +278,7 @@ public void testIndependentDispatch() throws Exception { assertTrue(result.hasGeneratedSource("test/Both_ForySerializer.java")); assertTrue( result.hasGeneratedResource("META-INF/proguard/fory-static-generated-test.Both.pro")); - assertTrue( - result.hasGeneratedResource("META-INF/com.android.tools/r8/fory-json-test.Both.pro")); + assertTrue(result.hasGeneratedResource("META-INF/proguard/fory-json-test.Both.pro")); } @Test @@ -299,8 +290,7 @@ public void testEnumConstants() throws Exception { + "import org.apache.fory.json.annotation.JsonType;\n" + "@JsonType public enum Status { READY, DONE }\n"); assertTrue(result.success, result.diagnostics()); - String rules = - result.generatedResource("META-INF/com.android.tools/r8/fory-json-test.Status.pro"); + String rules = result.generatedResource("META-INF/proguard/fory-json-test.Status.pro"); assertTrue(rules.contains("test.Status READY;"), rules); assertTrue(rules.contains("test.Status DONE;"), rules); assertFalse(rules.contains("$VALUES"), rules); @@ -320,9 +310,7 @@ public void testValidationMemberRules() throws Exception { + " public ValidationModel() {}\n" + "}\n"); assertTrue(result.success, result.diagnostics()); - String rules = - result.generatedResource( - "META-INF/com.android.tools/r8/fory-json-test.ValidationModel.pro"); + String rules = result.generatedResource("META-INF/proguard/fory-json-test.ValidationModel.pro"); assertTrue(rules.contains("class test.ValidationBase"), rules); assertTrue(rules.contains("java.lang.String getHidden();"), rules); } @@ -334,8 +322,7 @@ public void testDeterministicOutput() throws Exception { assertTrue(first.success, first.diagnostics()); assertTrue(second.success, second.diagnostics()); String metaPath = "test/Outer_Name$Model_With_Underscore_ForyJsonCodecMeta.java"; - String rulesPath = - "META-INF/com.android.tools/r8/fory-json-test.Outer_Name$Model_With_Underscore.pro"; + String rulesPath = "META-INF/proguard/fory-json-test.Outer_Name$Model_With_Underscore.pro"; assertEquals(first.generatedSource(metaPath), second.generatedSource(metaPath)); assertEquals(first.generatedResource(rulesPath), second.generatedResource(rulesPath)); } diff --git a/java/fory-core/src/main/java/org/apache/fory/reflect/TypeUseMetadata.java b/java/fory-core/src/main/java/org/apache/fory/reflect/TypeUseMetadata.java index 26a972e79d..e0ed250e73 100644 --- a/java/fory-core/src/main/java/org/apache/fory/reflect/TypeUseMetadata.java +++ b/java/fory-core/src/main/java/org/apache/fory/reflect/TypeUseMetadata.java @@ -93,9 +93,15 @@ public static boolean isNullable(Object typeUse) { } private static Support loadSupport() { + // ART can load JvmTypeUseMetadata while deferring linkage of missing type-use methods until + // their first invocation. Probe the API itself before selecting the implementation so Android + // API 26 never reaches those deferred references. + try { + Field.class.getMethod("getAnnotatedType"); + } catch (NoSuchMethodException | LinkageError e) { + return NoTypeUseSupport.INSTANCE; + } try { - // Keep the JVM implementation loaded by name so Android/R8 can fall back before resolving - // JVM-only type-use descriptors from JvmTypeUseMetadata. String className = TypeUseMetadata.class.getPackage().getName() + ".Jvm" + "TypeUseMetadata"; Class cls = Class.forName(className, true, TypeUseMetadata.class.getClassLoader()); return (Support) cls.getDeclaredConstructor().newInstance(); diff --git a/java/fory-core/src/test/java/org/apache/fory/serializer/AndroidJvmRoundTripTest.java b/java/fory-core/src/test/java/org/apache/fory/serializer/AndroidJvmRoundTripTest.java index 363ef52761..67c1614773 100644 --- a/java/fory-core/src/test/java/org/apache/fory/serializer/AndroidJvmRoundTripTest.java +++ b/java/fory-core/src/test/java/org/apache/fory/serializer/AndroidJvmRoundTripTest.java @@ -61,6 +61,7 @@ import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.platform.AndroidSupport; import org.apache.fory.reflect.TypeRef; +import org.apache.fory.reflect.TypeUseMetadata; import org.apache.fory.serializer.scala.SingletonCollectionSerializer; import org.apache.fory.serializer.scala.SingletonMapSerializer; import org.apache.fory.serializer.scala.SingletonObjectSerializer; @@ -126,12 +127,16 @@ public void testForcedAndroidJvmModeKeepsTypeUseMetadata() throws Exception { Assert.assertTrue(AndroidSupport.IS_ANDROID); Field nameField = TypeUseStruct.class.getDeclaredField("name"); - TypeExtMeta nameMeta = TypeRef.ofTypeUse(nameField.getAnnotatedType()).getTypeExtMeta(); + TypeExtMeta nameMeta = + TypeRef.ofTypeUse(TypeUseMetadata.fieldTypeUse(nameField)).getTypeExtMeta(); Assert.assertTrue(nameMeta.nullable()); Field codesField = TypeUseStruct.class.getDeclaredField("codes"); TypeExtMeta elementMeta = - TypeRef.ofTypeUse(codesField.getAnnotatedType()).getTypeArguments().get(0).getTypeExtMeta(); + TypeRef.ofTypeUse(TypeUseMetadata.fieldTypeUse(codesField)) + .getTypeArguments() + .get(0) + .getTypeExtMeta(); Assert.assertEquals(elementMeta.typeId(), Types.INT32); Assert.assertFalse(elementMeta.trackingRef()); }