diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 8184e7553..3864e15c3 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -42,6 +42,7 @@ require("./tests/testGC"); require("./tests/testsMemoryManagement"); require("./tests/testFieldGetSet"); require("./tests/extendedClassesTests"); +require("./tests/testNativeESClasses"); //require("./tests/extendClassNameTests"); // as tests now run with SBG, this test fails the whole build process require("./tests/testJniReferenceLeak"); require("./tests/testNativeModules"); diff --git a/test-app/app/src/main/assets/app/tests/testNativeESClasses.js b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js new file mode 100644 index 000000000..d013be256 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js @@ -0,0 +1,338 @@ +describe("Tests native ES class extensions (class X extends NativeType)", function () { + + var appClassLoader = com.tns.Runtime.class.getClassLoader(); + + // DummyClass.method2(Object) returns "obj=" + obj.toString(), forcing a Java-side virtual + // toString dispatch through the generated proxy. (java.lang.String.valueOf is unusable for + // this: accessing `java.lang.String.null` in other suites permanently replaces `valueOf` on + // the ctor function with the runtime's null-returning valueOf.) + function javaToString(obj) { + return new com.tns.tests.DummyClass().method2(obj); + } + + it("When_extending_a_class_with_es_class_syntax_instances_should_construct_and_dispatch_overrides", function () { + class EsButton extends com.tns.tests.Button1 { + getIMAGE_ID_PROP() { + return "es class override"; + } + } + + var button = new EsButton(); + + expect(button instanceof EsButton).toBe(true); + expect(button instanceof com.tns.tests.Button1).toBe(true); + expect(button.getIMAGE_ID_PROP()).toBe("es class override"); + // non-overridden base methods still work + expect(button.echo("hello")).toBe("hello"); + }); + + it("When_java_calls_a_virtual_method_it_should_dispatch_to_the_es_class_override", function () { + class EchoButton extends com.tns.tests.Button1 { + echo(s) { + return "es:" + s; + } + } + + var button = new EchoButton(); + + // triggerEcho calls this.echo(s) on the Java side - it must route + // through the generated proxy back into the ES class method + expect(button.triggerEcho("x")).toBe("es:x"); + }); + + it("When_calling_super_method_from_an_es_class_override_it_should_invoke_the_java_implementation", function () { + class SuperEchoButton extends com.tns.tests.Button1 { + echo(s) { + return "es:" + super.echo(s); + } + } + + var button = new SuperEchoButton(); + + expect(button.echo("y")).toBe("es:y"); + // round trip: Java triggerEcho -> proxy echo -> JS override -> super.echo -> Java echo + expect(button.triggerEcho("z")).toBe("es:z"); + }); + + it("When_the_es_class_constructor_passes_arguments_to_super_the_matching_java_constructor_should_be_used", function () { + class CtorButton extends com.tns.tests.Button1 { + constructor(value) { + super(value); // Button1(int) overload + this.value = value; + } + } + + var button = new CtorButton(5); + + expect(button.value).toBe(5); + expect(button instanceof com.tns.tests.Button1).toBe(true); + }); + + it("When_accessing_the_class_property_before_any_instance_exists_the_class_should_be_registered_lazily", function () { + class LazyTouch extends java.lang.Object { + toString() { + return "lazy touch"; + } + } + + // no `new` has happened - static touch must register the proxy class + var clazz = LazyTouch.class; + + expect(clazz).not.toBe(null); + expect(clazz.getName()).toContain("LazyTouch"); + + // the class is fully functional before any JS-side construction: + // Java instantiates it through reflection and dispatches to the JS override + var created = clazz.newInstance(); + expect(javaToString(created)).toBe("obj=lazy touch"); + }); + + it("When_passing_the_es_class_to_a_java_method_expecting_a_class_it_should_marshal_to_java_lang_Class", function () { + class MarshalledClass extends com.tns.tests.DummyClass { + } + + // Class.isAssignableFrom(Class) - MarshalledClass is registered lazily during marshalling + expect(com.tns.tests.DummyClass.class.isAssignableFrom(MarshalledClass)).toBe(true); + expect(java.lang.Object.class.isAssignableFrom(MarshalledClass)).toBe(true); + }); + + it("When_passing_the_es_class_where_java_lang_Object_is_expected_it_should_marshal_to_its_java_lang_Class", function () { + class ObjectMarshalledClass extends java.lang.Object { + } + + var list = new java.util.ArrayList(); + list.add(ObjectMarshalledClass); + + var stored = list.get(0); + expect(stored.getName()).toBe(ObjectMarshalledClass.class.getName()); + }); + + it("When_extending_an_es_class_that_extends_a_native_class_each_level_should_get_its_own_proxy", function () { + class LevelOne extends com.tns.tests.Button1 { + getIMAGE_ID_PROP() { + return "level one"; + } + echo(s) { + return "L1:" + s; + } + } + + class LevelTwo extends LevelOne { + getIMAGE_ID_PROP() { + return "level two + " + super.getIMAGE_ID_PROP(); + } + } + + var two = new LevelTwo(); + expect(two instanceof LevelTwo).toBe(true); + expect(two instanceof LevelOne).toBe(true); + expect(two instanceof com.tns.tests.Button1).toBe(true); + expect(two.getIMAGE_ID_PROP()).toBe("level two + level one"); + // method defined only on the intermediate level must be part of the proxy overrides + expect(two.triggerEcho("q")).toBe("L1:q"); + + var one = new LevelOne(); + expect(one.getIMAGE_ID_PROP()).toBe("level one"); + expect(one instanceof LevelTwo).toBe(false); + + expect(one.getClass().getName()).not.toBe(two.getClass().getName()); + }); + + it("When_constructing_the_es_class_multiple_times_all_instances_should_share_one_proxy_class", function () { + class SharedProxyButton extends com.tns.tests.Button1 { + } + + var first = new SharedProxyButton(); + var second = new SharedProxyButton(); + + expect(first.getClass().equals(second.getClass())).toBe(true); + expect(first.getClass().equals(SharedProxyButton.class)).toBe(true); + }); + + it("When_reading_base_class_statics_through_the_es_class_they_should_resolve_to_the_java_members", function () { + class StaticsButton extends com.tns.tests.Button1 { + static jsStatic = 42; + static jsStaticMethod() { + return "js static"; + } + } + + // Java statics are reachable through the constructor prototype chain + expect(StaticsButton.STATIC_IMAGE_ID).toBe("static image id"); + expect(StaticsButton.SGetStaticImageId()).toBe("static image id"); + + // plain JS statics live on the JS constructor and are untouched + expect(StaticsButton.jsStatic).toBe(42); + expect(StaticsButton.jsStaticMethod()).toBe("js static"); + }); + + it("When_the_es_class_is_registered_its_proxy_should_be_discoverable_through_the_app_class_loader", function () { + class DiscoverableEsClass extends java.lang.Object { + toString() { + return "discoverable es class"; + } + } + + var instance = new DiscoverableEsClass(); + var className = instance.getClass().getName(); + + var found = java.lang.Class.forName(className, false, appClassLoader); + + expect(found.getName()).toBe(className); + expect(found.equals(instance.getClass())).toBe(true); + expect(found.equals(DiscoverableEsClass.class)).toBe(true); + }); + + it("When_implementing_an_interface_with_es_class_syntax_java_should_dispatch_to_the_js_methods", function () { + var runCount = 0; + + class EsRunnable extends java.lang.Runnable { + run() { + runCount++; + } + } + + var runnable = new EsRunnable(); + expect(runnable instanceof java.lang.Runnable).toBe(true); + + // Thread.run() (not started) invokes target.run() synchronously on the current thread + var thread = new java.lang.Thread(runnable); + thread.run(); + + expect(runCount).toBe(1); + }); + + it("When_declaring_static_interfaces_the_proxy_should_implement_them", function () { + var ran = { value: false }; + + class WithInterfaces extends java.lang.Object { + static interfaces = [java.lang.Runnable]; + + run() { + ran.value = true; + } + } + + var instance = new WithInterfaces(); + + expect(instance instanceof java.lang.Runnable).toBe(true); + + var thread = new java.lang.Thread(instance); + thread.run(); + + expect(ran.value).toBe(true); + }); + + it("When_getting_the_class_name_it_should_be_stable_and_descriptive", function () { + class StableNameClass extends java.lang.Object { + } + + var name = StableNameClass.class.getName(); + + // runtime generated proxies are named _es_, mirroring the + // legacy ____ scheme (the com.tns.gen prefix is only + // present on build-time pre-generated bindings) + expect(name).toContain("java.lang.Object_es"); + expect(name).toContain("StableNameClass"); + // repeated access resolves to the very same class + expect(StableNameClass.class.getName()).toBe(name); + }); + + it("When_passing_an_es_class_instance_to_java_and_reading_it_back_it_should_be_the_same_object", function () { + class RoundTripObject extends java.lang.Object { + toString() { + return "round trip"; + } + } + + var instance = new RoundTripObject(); + var list = new java.util.ArrayList(); + list.add(instance); + + var stored = list.get(0); + expect(stored.equals(instance)).toBe(true); + expect(javaToString(stored)).toBe("obj=round trip"); + }); + + it("When_calling_extend_on_an_es_class_it_should_throw_a_descriptive_error", function () { + class NotExtendable extends com.tns.tests.Button1 { + } + + expect(function () { + NotExtendable.extend({ + toString: function () { + return "should not work"; + } + }); + }).toThrow(); + }); + + it("When_extending_a_class_created_with_legacy_extend_the_old_behavior_should_be_preserved", function () { + var LegacyButton = com.tns.tests.Button1.extend({ + getIMAGE_ID_PROP: function () { + return "legacy override"; + } + }); + + class EsOnLegacy extends LegacyButton { + } + + // the legacy proxy is used - ES levels above `.extend()`-created classes get no proxy of their own + var instance = new EsOnLegacy(); + expect(instance instanceof com.tns.tests.Button1).toBe(true); + expect(instance.getIMAGE_ID_PROP()).toBe("legacy override"); + }); + + it("When_a_plain_js_class_hierarchy_is_used_the_runtime_should_not_interfere", function () { + class PlainBase { + value() { + return 1; + } + } + + class PlainDerived extends PlainBase { + value() { + return super.value() + 1; + } + } + + var plain = new PlainDerived(); + expect(plain.value()).toBe(2); + expect(function () { + return plain instanceof java.lang.Object; + }).not.toThrow(); + }); + + it("When_the_NativeClass_decorator_is_applied_it_should_be_a_noop", function () { + expect(typeof global.NativeClass).toBe("function"); + + const DecoratedButton = global.NativeClass(class DecoratedButton extends com.tns.tests.Button1 { + getIMAGE_ID_PROP() { + return "decorated"; + } + }); + + var button = new DecoratedButton(); + expect(button.getIMAGE_ID_PROP()).toBe("decorated"); + }); + + it("When_anonymous_es_classes_extend_native_types_each_should_get_a_distinct_proxy", function () { + var First = class extends java.lang.Object { + toString() { + return "first anonymous"; + } + }; + var Second = class extends java.lang.Object { + toString() { + return "second anonymous"; + } + }; + + var firstInstance = new First(); + var secondInstance = new Second(); + + expect(javaToString(firstInstance)).toBe("obj=first anonymous"); + expect(javaToString(secondInstance)).toBe("obj=second anonymous"); + expect(firstInstance.getClass().equals(secondInstance.getClass())).toBe(false); + }); +}); diff --git a/test-app/app/src/main/assets/internal/ts_helpers.js b/test-app/app/src/main/assets/internal/ts_helpers.js index d1860dc47..788356b5f 100644 --- a/test-app/app/src/main/assets/internal/ts_helpers.js +++ b/test-app/app/src/main/assets/internal/ts_helpers.js @@ -166,6 +166,14 @@ } } + // No-op decorator for plain ES classes extending native types. + // The runtime registers such classes lazily (on first construction, static usage or when + // passed to native APIs), so the decorator only exists so shared iOS/Android sources and + // non-transformed code keep working. + function NativeClass(target) { + return target; + } + Object.defineProperty(global, "__native", { value: __native }); Object.defineProperty(global, "__extends", { value: __extends }); Object.defineProperty(global, "__decorate", { value: __decorate }); @@ -174,4 +182,7 @@ global.JavaProxy = JavaProxy; } global.Interfaces = Interfaces; + if (!global.NativeClass) { + global.NativeClass = NativeClass; + } })() \ No newline at end of file diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 5ce450fef..78ee14d54 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -163,6 +163,50 @@ jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassN return globalRefToGeneratedClass; } +jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassName, + const string &fullClassName, + const vector &methodOverrides, + const vector &implementedInterfaces, + bool isInterface) { + JEnv env; + jclass globalRefToGeneratedClass = env.CheckForClassInCache(fullClassName); + + if (globalRefToGeneratedClass == nullptr) { + JniLocalRef javaBaseClassName(env.NewStringUTF(baseClassName.c_str())); + JniLocalRef javaFullClassName(env.NewStringUTF(fullClassName.c_str())); + + jobjectArray methodOverridesArr = GetJavaStringArray(env, methodOverrides.size()); + for (size_t i = 0; i < methodOverrides.size(); i++) { + JniLocalRef name(env.NewStringUTF(methodOverrides[i].c_str())); + env.SetObjectArrayElement(methodOverridesArr, i, name); + } + + jobjectArray implementedInterfacesArr = GetJavaStringArray(env, implementedInterfaces.size()); + for (size_t i = 0; i < implementedInterfaces.size(); i++) { + JniLocalRef name(env.NewStringUTF(implementedInterfaces[i].c_str())); + env.SetObjectArrayElement(implementedInterfacesArr, i, name); + } + + auto runtime = Runtime::GetRuntime(isolate); + + // create or load generated binding (java class) + jclass generatedClass = (jclass) env.CallObjectMethod(runtime->GetJavaRuntime(), + RESOLVE_CLASS_METHOD_ID, + (jstring) javaBaseClassName, + (jstring) javaFullClassName, + methodOverridesArr, + implementedInterfacesArr, + isInterface); + + globalRefToGeneratedClass = env.InsertClassIntoCache(fullClassName, generatedClass); + + env.DeleteGlobalRef(methodOverridesArr); + env.DeleteGlobalRef(implementedInterfacesArr); + } + + return globalRefToGeneratedClass; +} + // Called by ExtendMethodCallback when extending a class string CallbackHandlers::ResolveClassName(Isolate *isolate, jclass &clazz) { auto runtime = Runtime::GetRuntime(isolate); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index eddcca93d..75b3f7635 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -41,6 +41,18 @@ namespace tns { const v8::Local &implementationObject, bool isInterface); + /* + * ResolveClass variant with explicitly collected method override names and implemented + * interface names. Used for plain ES class extensions where the overrides span multiple + * prototype levels and are non-enumerable (so the implementationObject-based scan above + * cannot see them). + */ + static jclass ResolveClass(v8::Isolate *isolate, const std::string &baseClassName, + const std::string &fullClassName, + const std::vector &methodOverrides, + const std::vector &implementedInterfaces, + bool isInterface); + static std::string ResolveClassName(v8::Isolate *isolate, jclass &clazz); static v8::Local diff --git a/test-app/runtime/src/main/cpp/JsArgConverter.cpp b/test-app/runtime/src/main/cpp/JsArgConverter.cpp index e5d82f0bd..995f54761 100644 --- a/test-app/runtime/src/main/cpp/JsArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/JsArgConverter.cpp @@ -1,5 +1,6 @@ #include "JsArgConverter.h" #include "ObjectManager.h" +#include "MetadataNode.h" #include "JniSignatureParser.h" #include "JsArgToArrayConverter.h" #include "ArgConverter.h" @@ -151,6 +152,22 @@ bool JsArgConverter::ConvertArg(const Local &arg, int index) { if (!success) { sprintf(buff, "Cannot convert string to %s at index %d", typeSignature.c_str(), index); } + } else if (arg->IsFunction() && + (typeSignature == "Ljava/lang/Class;" || typeSignature == "Ljava/lang/Object;") && + !MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As()).empty()) { + // a native type ctor (or a plain ES class extending one - registered lazily here) + // passed where Java expects a java.lang.Class. Typed nulls (`SomeClass.null`) and other + // functions resolve to an empty name and keep flowing through the object branch below. + auto typeName = MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As()); + JEnv env; + jclass clazz = env.FindClass(typeName); + success = clazz != nullptr; + if (success) { + // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it + SetConvertedObject(index, clazz, true /* isGlobal */); + } else { + sprintf(buff, "Cannot convert function to %s at index %d", typeSignature.c_str(), index); + } } else if (arg->IsObject()) { auto context = m_isolate->GetCurrentContext(); auto jsObject = arg->ToObject(context).ToLocalChecked(); diff --git a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp index e7d424cbb..11d3769e9 100644 --- a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp +++ b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp @@ -134,6 +134,20 @@ bool JsArgToArrayConverter::ConvertArg(Local context, const LocalIsFunction() && !MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As()).empty()) { + // a native type ctor (or a plain ES class extending one - registered lazily here) + // marshals to its java.lang.Class. Typed nulls (`SomeClass.null`) and other functions + // resolve to an empty name and keep flowing through the object branch below. + auto typeName = MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As()); + jclass clazz = env.FindClass(typeName); + if (clazz != nullptr) { + // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it + SetConvertedObject(env, index, clazz, true /* isGlobal */); + success = true; + } else { + s << "Cannot marshal JavaScript function at index " << index + << " to Java type. Only native type constructors can be marshalled (to java.lang.Class)."; + } } else if (arg->IsObject()) { auto jsObj = arg->ToObject(context).ToLocalChecked(); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 7d3335fb2..3cac01e24 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -11,6 +11,7 @@ #include "Runtime.h" #include #include +#include #include #include #include @@ -147,7 +148,20 @@ bool MetadataNode::IsNodeTypeInterface() { } string MetadataNode::GetTypeMetadataName(Isolate* isolate, Local& value) { - auto data = GetTypeMetadata(isolate, value.As()); + if (value.IsEmpty() || !value->IsFunction()) { + throw NativeScriptException(string("Cannot resolve native type - the value is not a constructor function.")); + } + + auto func = value.As(); + auto data = TryGetTypeMetadata(isolate, func); + if (data == nullptr) { + // may be a not-yet-registered plain ES class extension + data = EnsureExtendedESClass(isolate, func); + } + + if (data == nullptr) { + throw NativeScriptException(string("Cannot resolve native type - the function does not stand for a native type or an extension of one.")); + } return data->name; } @@ -258,7 +272,22 @@ void MetadataNode::ClassAccessorGetterCallback(Local property, const Prope try { auto thiz = info.This(); auto isolate = info.GetIsolate(); - auto data = GetTypeMetadata(isolate, thiz.As()); + + if (thiz.IsEmpty() || !thiz->IsFunction()) { + throw NativeScriptException(string("The 'class' property may only be accessed on a native type or an extended class constructor function.")); + } + + auto func = thiz.As(); + auto data = TryGetTypeMetadata(isolate, func); + if (data == nullptr) { + // Plain ES class extension accessed statically before any instance was constructed + // (e.g. `MyView.class`) - lazily register it now + data = EnsureExtendedESClass(isolate, func); + } + + if (data == nullptr) { + throw NativeScriptException(string("Cannot resolve java.lang.Class - the function does not stand for a native type or an extension of one.")); + } auto value = CallbackHandlers::FindClass(isolate, data->name); info.GetReturnValue().Set(value); @@ -1051,6 +1080,310 @@ void MetadataNode::SetTypeMetadata(Isolate* isolate, Local value, Type V8SetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), External::New(isolate, data)); } +MetadataNode::TypeMetadata* MetadataNode::TryGetTypeMetadata(Isolate* isolate, const Local& value) { + Local hiddenVal; + V8GetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), hiddenVal); + + if (hiddenVal.IsEmpty() || !hiddenVal->IsExternal()) { + return nullptr; + } + + return reinterpret_cast(hiddenVal.As()->Value()); +} + +std::string MetadataNode::TryResolveClassCtorTypeName(Isolate* isolate, const Local& func) { + // A ctor function that had its `.null` accessed doubles as the typed null value for its + // type (see NullObjectAccessorGetterCallback - the marker private is set on the ctor and + // the ctor itself is returned). Such functions must marshal as typed nulls, not as + // java.lang.Class references. + Local nullNodeMarker; + V8GetPrivateValue(isolate, func, V8StringConstants::GetNullNodeName(isolate), nullNodeMarker); + if (!nullNodeMarker.IsEmpty()) { + return std::string(); + } + + auto typeMetadata = TryGetTypeMetadata(isolate, func); + + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, func); + } + + return typeMetadata != nullptr ? typeMetadata->name : std::string(); +} + +namespace { +// short deterministic identifier so the same ES class gets the same proxy class name across +// application launches (keeps the DexFactory on-disk dex cache warm) without depending on +// line/column numbers that shift with unrelated code edits +std::string HashESClassId(const std::string& input) { + uint32_t hash = 2166136261u; + for (char c : input) { + hash ^= static_cast(c); + hash *= 16777619u; + } + + char buff[9]; + snprintf(buff, sizeof(buff), "%08x", hash); + return std::string(buff); +} + +std::string SanitizeESClassNamePart(const std::string& name) { + std::string result; + result.reserve(name.size()); + for (char c : name) { + bool isValid = isalpha(c) || isdigit(c) || c == '_'; + result += isValid ? c : '_'; + } + return result; +} + +// True only for genuine `class` syntax constructors. Function source text is the reliable +// discriminator: per spec, Function.prototype.toString for a class constructor reproduces the +// `class` declaration/expression source (possibly behind leading comments/whitespace, which V8 +// does not emit for the class case - the text starts with "class"). +bool IsESClassConstructor(v8::Isolate* isolate, const v8::Local& func) { + auto context = isolate->GetCurrentContext(); + v8::Local sourceText; + if (!func->FunctionProtoToString(context).ToLocal(&sourceText)) { + return false; + } + + auto source = tns::ArgConverter::ConvertToString(sourceText); + return source.compare(0, 5, "class") == 0; +} +} // namespace + +MetadataNode::TypeMetadata* MetadataNode::EnsureExtendedESClass(Isolate* isolate, Local ctorFunc) { + // Already registered - a native class ctor, a legacy `.extend()`-created ctor or an + // ES class ctor registered by a previous call + auto existingMetadata = TryGetTypeMetadata(isolate, ctorFunc); + if (existingMetadata != nullptr) { + return existingMetadata; + } + + auto context = isolate->GetCurrentContext(); + + // Only genuine `class` syntax constructors participate in lazy ES registration. Downleveled + // ES5 "classes" (TypeScript ES5 output, the JavaProxy decorator target, ts_helpers __extends + // children) have the same shape - an untagged function whose prototype chain reaches a native + // ctor - but must keep flowing through the legacy `.extend()` pipeline. + if (!IsESClassConstructor(isolate, ctorFunc)) { + return nullptr; + } + + // Walk the constructor prototype chain (mirrors the `class X extends Y` chain) and collect + // every plain (unregistered) ES constructor level until we reach a constructor holding type + // metadata. ES-registered ancestors are flattened into this registration; legacy + // `.extend()`-created ancestors are not supported and make this function bail out so callers + // preserve their old behavior. + std::vector> chainCtors; + Local current = ctorFunc; + TypeMetadata* baseTypeMetadata = nullptr; + while (true) { + chainCtors.push_back(current); + + Local parentValue = current->GetPrototype(); + if (parentValue.IsEmpty() || !parentValue->IsObject() || !parentValue->IsFunction()) { + // no native type in the chain - a plain JS class, leave it alone + return nullptr; + } + + auto parent = parentValue.As(); + auto parentMetadata = TryGetTypeMetadata(isolate, parent); + if (parentMetadata == nullptr) { + current = parent; + continue; + } + + if (parentMetadata->isESDerived) { + // Flatten: the parent's registered proxy class sits directly under the pure native + // base, so keep walking (collecting the parent's prototype for scanning) until we + // reach it + current = parent; + continue; + } + + auto cachedData = GetCachedExtendedClassData(isolate, parentMetadata->name); + if (cachedData.extendedCtorFunction != nullptr) { + // legacy `.extend()`-created ancestor - not supported for ES class chaining + return nullptr; + } + + baseTypeMetadata = parentMetadata; + break; + } + + string baseClassName = baseTypeMetadata->name; + auto node = GetOrCreate(baseClassName); + if (node == nullptr) { + return nullptr; + } + + uint8_t nodeType = s_metadataReader.GetNodeType(node->m_treeNode); + bool isInterface = s_metadataReader.IsNodeTypeInterface(nodeType); + + // Collect overridden method names level by level, most-derived first, so JS shadowing + // semantics carry over. ES class methods are non-enumerable, so unlike the legacy + // implementation-object scan we must use ALL_PROPERTIES. + std::vector methodOverrides; + std::vector implementedInterfaces; + robin_hood::unordered_set visitedNames; + std::string nativeClassName; + + auto prototypeKey = V8StringConstants::GetPrototype(isolate); + auto interfacesKey = ArgConverter::ConvertToV8String(isolate, "interfaces"); + auto nativeClassNameKey = ArgConverter::ConvertToV8String(isolate, "nativeClassName"); + auto propertyFilter = static_cast(PropertyFilter::ALL_PROPERTIES | PropertyFilter::SKIP_SYMBOLS); + + for (auto& levelCtor : chainCtors) { + Local protoValue; + if (!levelCtor->Get(context, prototypeKey).ToLocal(&protoValue) || protoValue.IsEmpty() || !protoValue->IsObject()) { + continue; + } + auto levelPrototype = protoValue.As(); + + Local propNames; + if (levelPrototype->GetOwnPropertyNames(context, propertyFilter).ToLocal(&propNames)) { + for (uint32_t i = 0; i < propNames->Length(); i++) { + Local nameValue; + if (!propNames->Get(context, i).ToLocal(&nameValue) || !nameValue->IsString()) { + continue; + } + + string name = ArgConverter::ConvertToString(nameValue.As()); + if (name == "constructor" || name == "super") { + continue; + } + + // skip names already handled by a more derived level (JS shadowing semantics) + if (!visitedNames.insert(name).second) { + continue; + } + + // inspect the descriptor instead of reading the property, so user-defined + // accessors are not invoked during registration + Local descriptor; + if (!levelPrototype->GetOwnPropertyDescriptor(context, nameValue.As()).ToLocal(&descriptor) + || descriptor.IsEmpty() || !descriptor->IsObject()) { + continue; + } + + Local methodValue; + if (descriptor.As()->Get(context, V8StringConstants::GetValue(isolate)).ToLocal(&methodValue) + && !methodValue.IsEmpty() && methodValue->IsFunction()) { + methodOverrides.push_back(name); + } + } + } + + // `static interfaces = [...]` - additional interfaces the proxy should implement + bool hasOwnInterfaces; + if (levelCtor->HasOwnProperty(context, interfacesKey).To(&hasOwnInterfaces) && hasOwnInterfaces) { + Local interfacesValue; + if (levelCtor->Get(context, interfacesKey).ToLocal(&interfacesValue) && interfacesValue->IsArray()) { + auto interfacesArr = interfacesValue.As(); + for (uint32_t i = 0; i < interfacesArr->Length(); i++) { + Local element; + if (!interfacesArr->Get(context, i).ToLocal(&element) || !element->IsFunction()) { + continue; + } + + auto interfaceName = GetTypeMetadataName(isolate, element); + interfaceName = Util::ReplaceAll(interfaceName, std::string("/"), std::string(".")); + if (std::find(implementedInterfaces.begin(), implementedInterfaces.end(), interfaceName) == implementedInterfaces.end()) { + implementedInterfaces.push_back(interfaceName); + } + } + } + } + + // `static nativeClassName = 'com.my.Thing'` - explicit proxy class name (most-derived wins) + if (nativeClassName.empty()) { + bool hasOwnNativeClassName; + if (levelCtor->HasOwnProperty(context, nativeClassNameKey).To(&hasOwnNativeClassName) && hasOwnNativeClassName) { + Local nativeClassNameValue; + if (levelCtor->Get(context, nativeClassNameKey).ToLocal(&nativeClassNameValue) && nativeClassNameValue->IsString()) { + nativeClassName = ArgConverter::ConvertToString(nativeClassNameValue.As()); + } + } + } + } + + // Compute the proxy class name + string fullClassName; + if (!nativeClassName.empty() && nativeClassName.find('.') != string::npos) { + fullClassName = nativeClassName; + } else if (isInterface) { + // interface extensions reuse the shared interface proxy, exactly like + // `new SomeInterface({...})` - all methods dispatch back to JS through the instance + fullClassName = node->m_implType; + } else { + string className; + auto jsClassName = ctorFunc->GetName(); + if (!jsClassName.IsEmpty() && jsClassName->IsString()) { + className = SanitizeESClassNamePart(ArgConverter::ConvertToString(jsClassName.As())); + } + if (className.empty()) { + className = "ESClass"; + } + + string scriptName; + auto scriptOrigin = ctorFunc->GetScriptOrigin(); + auto resourceName = scriptOrigin.ResourceName(); + if (!resourceName.IsEmpty() && resourceName->IsString()) { + scriptName = ArgConverter::ConvertToString(resourceName.As()); + } + + string extendNameAndLocation = "es" + HashESClassId(scriptName + "|" + baseClassName + "|" + className) + "_" + className; + string candidate = TNS_PREFIX + CreateFullClassName(baseClassName, extendNameAndLocation); + + // collision handling for distinct classes that produce the same deterministic name + // (e.g. a class factory evaluated multiple times in the same script) + fullClassName = candidate; + int suffix = 2; + while (GetCachedExtendedClassData(isolate, fullClassName).extendedCtorFunction != nullptr) { + fullClassName = candidate + "_" + std::to_string(suffix++); + } + } + + // Resolve (generate or load) the Java proxy class through the regular DexFactory pipeline + auto clazz = CallbackHandlers::ResolveClass(isolate, baseClassName, fullClassName, methodOverrides, implementedInterfaces, isInterface); + auto fullExtendedName = CallbackHandlers::ResolveClassName(isolate, clazz); + + // Tag the ES ctor the same way ExtendMethodCallback tags `.extend()`-created functions, so + // the rest of the runtime (construction, Java-initiated instantiation, `.class`, marshalling) + // treats it like any other extended class ctor + auto typeMetadata = new TypeMetadata(fullExtendedName, true /* isESDerived */); + SetTypeMetadata(isolate, ctorFunc, typeMetadata); + + // The ES class prototype acts as the implementation object. Mark it the way + // ExtendMethodCallback marks implementation objects, so GetImplementationObject (used + // during Java-initiated instantiation) can find it on the instance prototype chain. + Local ctorPrototypeValue; + if (ctorFunc->Get(context, prototypeKey).ToLocal(&ctorPrototypeValue) && ctorPrototypeValue->IsObject()) { + auto ctorPrototype = ctorPrototypeValue.As(); + auto implementationObjectPropertyName = V8StringConstants::GetClassImplementationObject(isolate); + Local hiddenVal; + V8GetPrivateValue(isolate, ctorPrototype, implementationObjectPropertyName, hiddenVal); + if (hiddenVal.IsEmpty()) { + V8SetPrivateValue(isolate, ctorPrototype, implementationObjectPropertyName, String::NewFromUtf8(isolate, fullExtendedName.c_str()).ToLocalChecked()); + } + } + + s_name2NodeCache.emplace(fullExtendedName, node); + + auto cache = GetMetadataNodeCache(isolate); + auto itCached = cache->ExtendedCtorFuncCache.find(fullExtendedName); + if (itCached == cache->ExtendedCtorFuncCache.end()) { + ExtendedClassCacheData cacheData(ctorFunc, fullExtendedName, node); + cache->ExtendedCtorFuncCache.emplace(fullExtendedName, cacheData); + } + + DEBUG_WRITE("EnsureExtendedESClass: registered %s (base %s)", fullExtendedName.c_str(), baseClassName.c_str()); + + return typeMetadata; +} + MetadataNode* MetadataNode::GetInstanceMetadata(Isolate* isolate, const Local& value) { MetadataNode* node = nullptr; auto cache = GetMetadataNodeCache(isolate); @@ -1129,6 +1462,30 @@ void MetadataNode::InterfaceConstructorCallback(const v8::FunctionCallbackInfo v8ExtendName; auto context = isolate->GetCurrentContext(); + // Plain ES class implementing an interface: `class Handler extends java.lang.Runnable {}` + // invokes this callback through `super()` with new.target set to the ES constructor and + // no implementation object argument. The methods live on the ES class prototype. + auto newTargetValue = info.NewTarget(); + if (!newTargetValue.IsEmpty() && newTargetValue->IsFunction()) { + auto newTargetFunc = newTargetValue.As(); + auto typeMetadata = TryGetTypeMetadata(isolate, newTargetFunc); + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, newTargetFunc); + } + + if (typeMetadata != nullptr && typeMetadata->isESDerived) { + auto esImplementationObject = newTargetFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked().As(); + + SetInstanceMetadata(isolate, thiz, node); + thiz->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); + V8SetPrivateValue(isolate, thiz, V8StringConstants::GetImplementationObject(isolate), esImplementationObject); + + ArgsWrapper esArgWrapper(info, ArgType::Interface); + CallbackHandlers::RegisterInstance(isolate, thiz, typeMetadata->name, esArgWrapper, esImplementationObject, true); + return; + } + } + if (info.Length() == 1) { if (!info[0]->IsObject()) { throw NativeScriptException(string("First argument must be implementation object")); @@ -1191,6 +1548,32 @@ void MetadataNode::ClassConstructorCallback(const v8::FunctionCallbackInfom_name; + // Plain ES class extension: `class MyView extends android.view.View {}` invokes this + // callback through `super(...)` with new.target set to the most derived ES constructor. + // Lazily register the ES class as an extended class and construct its proxy instead of + // the base class. + auto newTargetValue = info.NewTarget(); + if (!newTargetValue.IsEmpty() && newTargetValue->IsFunction()) { + auto newTargetFunc = newTargetValue.As(); + auto typeMetadata = TryGetTypeMetadata(isolate, newTargetFunc); + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, newTargetFunc); + } + + if (typeMetadata != nullptr && typeMetadata->isESDerived) { + auto context = isolate->GetCurrentContext(); + auto implementationObject = newTargetFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked().As(); + + SetInstanceMetadata(isolate, thiz, node); + thiz->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); + V8SetPrivateValue(isolate, thiz, V8StringConstants::GetImplementationObject(isolate), implementationObject); + + ArgsWrapper esArgWrapper(info, ArgType::Class); + CallbackHandlers::RegisterInstance(isolate, thiz, typeMetadata->name, esArgWrapper, implementationObject, false, className); + return; + } + } + SetInstanceMetadata(isolate, thiz, node); ArgsWrapper argWrapper(info, ArgType::Class); @@ -1578,6 +1961,24 @@ void MetadataNode::ExtendMethodCallback(const v8::FunctionCallbackInfoIsFunction()) { + auto thisFunc = thisValue.As(); + auto thisMetadata = TryGetTypeMetadata(info.GetIsolate(), thisFunc); + bool isESClassReceiver = (thisMetadata != nullptr && thisMetadata->isESDerived) || + (thisMetadata == nullptr && IsESClassConstructor(info.GetIsolate(), thisFunc)); + if (isESClassReceiver) { + throw NativeScriptException(string("Cannot call 'extend' on a class extension created with ES class syntax. Extend it with `class X extends Y {}` instead.")); + } + } + } + Local implementationObject; Local extendName; string extendLocation; diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index 71e7bfc05..9ff75c342 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -59,6 +59,15 @@ class MetadataNode { static std::string GetTypeMetadataName(v8::Isolate* isolate, v8::Local& value); + /* + * Resolves the Java class name a constructor function stands for, lazily registering a + * Java proxy class when the function is a plain ES `class X extends NativeType {}` that + * has not been registered yet. Returns an empty string when the function is not part of + * a native inheritance chain. Used when marshalling a constructor function to a Java + * `java.lang.Class` (or `java.lang.Object`) argument. + */ + static std::string TryResolveClassCtorTypeName(v8::Isolate* isolate, const v8::Local& func); + static void onDisposeIsolate(v8::Isolate* isolate); static MetadataReader* getMetadataReader(); @@ -129,8 +138,24 @@ class MetadataNode { static TypeMetadata* GetTypeMetadata(v8::Isolate* isolate, const v8::Local& value); + // Safe variant of GetTypeMetadata - returns nullptr when the function carries no type + // metadata (e.g. a plain ES class constructor) instead of crashing + static TypeMetadata* TryGetTypeMetadata(v8::Isolate* isolate, const v8::Local& value); + static void SetTypeMetadata(v8::Isolate* isolate, v8::Local value, TypeMetadata* data); + /* + * Lazily registers a Java proxy class for a plain ES `class X extends NativeType {}` + * constructor function (no `.extend()` call, no downleveling). Walks the constructor + * prototype chain to the native base, collects overridden method names from every ES + * level's prototype and implemented interfaces from `static interfaces = [...]`, resolves + * the proxy class through the regular DexFactory pipeline and tags the constructor the + * same way `.extend()` tags its result (typemetadata + ExtendedCtorFuncCache entry). + * Returns nullptr when ctorFunc is not part of a native inheritance chain or the chain + * goes through a legacy `.extend()`-created class. Idempotent. + */ + static TypeMetadata* EnsureExtendedESClass(v8::Isolate* isolate, v8::Local ctorFunc); + static std::string CreateFullClassName(const std::string& className, const std::string& extendNameAndLocation); static void MethodCallback(const v8::FunctionCallbackInfo& info); static void InterfaceConstructorCallback(const v8::FunctionCallbackInfo& info); @@ -231,12 +256,16 @@ class MetadataNode { }; struct TypeMetadata { - TypeMetadata(const std::string& _name) + TypeMetadata(const std::string& _name, bool _isESDerived = false) : - name(_name) { + name(_name), isESDerived(_isESDerived) { } std::string name; + + // true when the class was registered lazily from a plain ES + // `class X extends NativeType {}` constructor (see EnsureExtendedESClass) + bool isESDerived; }; struct CtorCacheData {