@@ -3,8 +3,12 @@ package com.snap.valdi.schema
33import androidx.annotation.Keep
44import com.snap.valdi.exceptions.ValdiFatalException
55import com.snap.valdi.utils.arrayMap
6+ import java.io.ByteArrayOutputStream
67import java.lang.Exception
78import java.lang.reflect.Method
9+ import java.nio.ByteBuffer
10+ import java.nio.ByteOrder
11+ import java.nio.charset.StandardCharsets
812import kotlin.reflect.KClass
913
1014@Keep
@@ -106,52 +110,188 @@ private constructor(val type: Int,
106110 @JvmStatic
107111 fun getDescriptorForClass (cls : Class <* >): ValdiMarshallableObjectDescriptor {
108112 try {
109- if (cls.isInterface) {
110- val valdiInterface = cls.getAnnotation(ValdiInterface ::class .java)
111- if (valdiInterface != null ) {
112- return forInterface(
113- valdiInterface.schema,
114- valdiInterface.proxyClass.java,
115- valdiInterface.typeReferences,
116- valdiInterface.propertyReplacements)
117- }
113+ return descriptorFromAnnotations(cls)
114+ } catch (exc: Throwable ) {
115+ ValdiFatalException .handleFatal(exc, " Could not resolve descriptor for class ${cls.name} " )
116+ }
117+ }
118118
119- val valdiUntyped = cls.getAnnotation(ValdiUntypedClass ::class .java)
120- if (valdiUntyped != null ) {
121- return forUntyped()
122- }
119+ @JvmStatic
120+ private fun descriptorFromAnnotations (cls : Class <* >): ValdiMarshallableObjectDescriptor {
121+ if (cls.isInterface) {
122+ val valdiInterface = cls.getAnnotation(ValdiInterface ::class .java)
123+ if (valdiInterface != null ) {
124+ return forInterface(
125+ valdiInterface.schema,
126+ valdiInterface.proxyClass.java,
127+ valdiInterface.typeReferences,
128+ valdiInterface.propertyReplacements)
123129 }
124130
125- if (cls.isEnum) {
126- val valdiEnum = cls.getAnnotation(ValdiEnum ::class .java)
127- if (valdiEnum != null ) {
128- return when (valdiEnum.type) {
129- ValdiEnumType .INT -> forIntEnum(valdiEnum.schema, valdiEnum.propertyReplacements)
130- ValdiEnumType .STRING -> forStringEnum(valdiEnum.schema, valdiEnum.propertyReplacements)
131- }
131+ val valdiUntyped = cls.getAnnotation(ValdiUntypedClass ::class .java)
132+ if (valdiUntyped != null ) {
133+ return forUntyped()
134+ }
135+ }
136+
137+ if (cls.isEnum) {
138+ val valdiEnum = cls.getAnnotation(ValdiEnum ::class .java)
139+ if (valdiEnum != null ) {
140+ return when (valdiEnum.type) {
141+ ValdiEnumType .INT -> forIntEnum(valdiEnum.schema, valdiEnum.propertyReplacements)
142+ ValdiEnumType .STRING -> forStringEnum(valdiEnum.schema, valdiEnum.propertyReplacements)
132143 }
133144 }
145+ }
146+
147+ val valdiClass = cls.getAnnotation(ValdiClass ::class .java)
148+ if (valdiClass != null ) {
149+ return forClass(
150+ valdiClass.schema,
151+ valdiClass.typeReferences,
152+ valdiClass.propertyReplacements)
153+ }
154+
155+ val valdiFunction = cls.getAnnotation(ValdiFunctionClass ::class .java)
156+ if (valdiFunction != null ) {
157+ return forFunction(
158+ valdiFunction.schema,
159+ valdiFunction.typeReferences,
160+ valdiFunction.propertyReplacements)
161+ }
162+
163+ throw Exception (" Could not resolve Valdi Annotation" )
164+ }
165+
166+ // --- Batched descriptor fetch (JNI-overhead reduction). See valdi-jni-batch-plan.md ---
167+
168+ /* * Binary format version of the buffer produced by [getDescriptorClosure]. Must match the C++
169+ * parser in AndroidValueMarshallerRegistry. Bump on any layout change. */
170+ const val DESCRIPTOR_CLOSURE_FORMAT_VERSION : Int = 2
134171
135- val valdiClass = cls.getAnnotation(ValdiClass ::class .java)
136- if (valdiClass != null ) {
137- return forClass(
138- valdiClass.schema,
139- valdiClass.typeReferences,
140- valdiClass.propertyReplacements)
172+ /* *
173+ * Type-reference classes of [cls], read from its Valdi annotation. The annotation stores them
174+ * as already-resolved [KClass] (not names), so traversal needs no name→Class lookup and is
175+ * classloader-safe. Enums and untyped types have none.
176+ */
177+ @JvmStatic
178+ private fun referencedClasses (cls : Class <* >): Array <KClass <* >> {
179+ return cls.getAnnotation(ValdiClass ::class .java)?.typeReferences
180+ ? : cls.getAnnotation(ValdiInterface ::class .java)?.typeReferences
181+ ? : cls.getAnnotation(ValdiFunctionClass ::class .java)?.typeReferences
182+ ? : emptyArray()
183+ }
184+
185+ /* *
186+ * Whether [cls] is a Valdi marshallable type (carries one of the Valdi schema annotations).
187+ * Type references can point at non-marshallable classes (e.g. java.lang.Object from an untyped
188+ * field); those must be skipped by the closure walk — [getDescriptorForClass] would fatal on
189+ * them, and the registry resolves them through its own path instead.
190+ */
191+ @JvmStatic
192+ private fun hasValdiDescriptor (cls : Class <* >): Boolean {
193+ return cls.isAnnotationPresent(ValdiClass ::class .java) ||
194+ cls.isAnnotationPresent(ValdiInterface ::class .java) ||
195+ cls.isAnnotationPresent(ValdiFunctionClass ::class .java) ||
196+ cls.isAnnotationPresent(ValdiEnum ::class .java) ||
197+ cls.isAnnotationPresent(ValdiUntypedClass ::class .java)
198+ }
199+
200+ private fun writeLengthPrefixed (out : ByteArrayOutputStream , value : String ) {
201+ // Payloads are ASCII (schema syntax + JVM class names), so ISO-8859-1 is a 1:1 byte copy.
202+ // Length is u32: a large @ExportModel's schema can exceed 64KB, and a u16 prefix would
203+ // silently truncate it (size and 0xFFFF) and desync the rest of the buffer.
204+ val bytes = value.toByteArray(StandardCharsets .ISO_8859_1 )
205+ out .write(bytes.size and 0xFF )
206+ out .write((bytes.size ushr 8 ) and 0xFF )
207+ out .write((bytes.size ushr 16 ) and 0xFF )
208+ out .write((bytes.size ushr 24 ) and 0xFF )
209+ out .write(bytes)
210+ }
211+
212+ /* *
213+ * JVM class names already visited by a prior [getDescriptorClosure] call (every class packed
214+ * into a buffer, plus non-marshallable references that were skipped). Accumulated across calls
215+ * and reused as the walk's `visited` set so each call prunes to the not-yet-resolved frontier,
216+ * skipping already-cached classes and their subtrees.
217+ *
218+ * This mirrors the C++ registry's descriptor cache without C++ echoing it back each call: that
219+ * cache belongs to a process-singleton ([ValdiValueMarshallerRegistry.shared]), is append-only,
220+ * and is never cleared, so the two stay in sync by construction. Every call originates from the
221+ * C++ registry under its schema-registry lock, so access here is already serialized — no extra
222+ * synchronization needed. (If the two ever diverged, e.g. C++ dropped a buffer, the registry
223+ * self-heals via its legacy per-class fallback.)
224+ */
225+ private val resolvedClassNames = HashSet <String >()
226+
227+ /* *
228+ * Resolve [root] and its transitive type-reference closure, returning every not-yet-resolved
229+ * descriptor packed into one direct [ByteBuffer] for zero-copy parsing in C++. Replaces N
230+ * per-class getDescriptorForClass JNI round-trips (+ per-field/array read-backs) with a single
231+ * batched fetch; C++ caches the parsed descriptors by name so reference recursion becomes cache
232+ * hits. Classes packed by a prior call are tracked in [resolvedClassNames] and skipped (with
233+ * their subtrees), so each call costs only the new frontier, not the whole accumulated cache.
234+ *
235+ * Layout (little-endian, ASCII payloads):
236+ * [u8 version][u32 count] then, per entry:
237+ * [u32+className][u8 type][u32+schema][u32+propertyReplacements][u32+proxyClassName]
238+ * [u16 refCount]{ [u32+refName] }
239+ */
240+ @Keep
241+ @JvmStatic
242+ fun getDescriptorClosure (root : Class <* >): ByteBuffer {
243+ // Reuse the running set directly as `visited`: classes resolved by earlier calls (and their
244+ // subtrees) are skipped, so the walk only touches the new frontier. Serialized by the C++
245+ // registry lock (see [resolvedClassNames]).
246+ val visited = resolvedClassNames
247+ val queue = ArrayDeque <Class <* >>()
248+ queue.addLast(root)
249+
250+ val body = ByteArrayOutputStream (8192 )
251+ var count = 0
252+
253+ while (queue.isNotEmpty()) {
254+ // Use removeAt(0): the remove-first extension resolves to the Java 21
255+ // SequencedCollection method and NoSuchMethodErrors on older Android runtimes
256+ // (banned by check_unsupported_android_15_method_usage).
257+ val cls = queue.removeAt(0 )
258+ if (! visited.add(cls.name)) {
259+ continue
141260 }
261+ if (! hasValdiDescriptor(cls)) {
262+ // Non-marshallable reference (e.g. java.lang.Object from an untyped field). Don't
263+ // fetch (getDescriptorForClass would fatal) or recurse; the registry resolves such
264+ // references via its own path, falling back to legacy per-class resolution.
265+ continue
266+ }
267+
268+ val descriptor = getDescriptorForClass(cls)
269+ writeLengthPrefixed(body, cls.name)
270+ body.write(descriptor.type and 0xFF )
271+ writeLengthPrefixed(body, descriptor.schema)
272+ writeLengthPrefixed(body, descriptor.propertyReplacements ? : " " )
273+ writeLengthPrefixed(body, descriptor.proxyClass?.name ? : " " )
142274
143- val valdiFunction = cls.getAnnotation(ValdiFunctionClass ::class .java)
144- if (valdiFunction != null ) {
145- return forFunction(
146- valdiFunction.schema,
147- valdiFunction.typeReferences,
148- valdiFunction.propertyReplacements)
275+ val refNames = descriptor.typeReferences ? : emptyArray()
276+ body.write(refNames.size and 0xFF )
277+ body.write((refNames.size ushr 8 ) and 0xFF )
278+ for (refName in refNames) {
279+ writeLengthPrefixed(body, refName)
149280 }
281+ count++
150282
151- throw Exception ( " Could not resolve Valdi Annotation " )
152- } catch (exc : Throwable ) {
153- ValdiFatalException .handleFatal(exc, " Could not resolve descriptor for class ${cls.name} " )
283+ for (ref in referencedClasses(cls)) {
284+ queue.addLast(ref.java)
285+ }
154286 }
287+
288+ val bodyBytes = body.toByteArray()
289+ val buffer = ByteBuffer .allocateDirect(1 + 4 + bodyBytes.size).order(ByteOrder .LITTLE_ENDIAN )
290+ buffer.put(DESCRIPTOR_CLOSURE_FORMAT_VERSION .toByte())
291+ buffer.putInt(count)
292+ buffer.put(bodyBytes)
293+ buffer.flip()
294+ return buffer
155295 }
156296
157297 @JvmStatic
0 commit comments