@q9labsai/chalk-react-native 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,3 +41,38 @@ dependencies {
41
41
  implementation("com.facebook.react:react-android")
42
42
  implementation("androidx.core:core:1.16.0")
43
43
  }
44
+
45
+ def compatibilityClasses = layout.buildDirectory.dir("compatibility/classes")
46
+ def compileCompatibility = tasks.register("compileModuleApiCompatibility", JavaCompile) {
47
+ source fileTree("compatibility") { include "*.java" }
48
+ classpath = files()
49
+ destinationDirectory = compatibilityClasses
50
+ sourceCompatibility = "17"
51
+ targetCompatibility = "17"
52
+ }
53
+
54
+ // Check the bytecode RN actually reflects, including inferred/private/synthetic getters.
55
+ tasks.register("checkModuleApiCompatibility", JavaExec) {
56
+ dependsOn compileCompatibility, "compileDebugKotlin", "compileDebugJavaWithJavac",
57
+ "compileDebugUnitTestKotlin"
58
+ classpath = files(compatibilityClasses)
59
+ mainClass = "ModuleApiCompatibility"
60
+ doFirst {
61
+ def manifest = layout.buildDirectory.file("compatibility/classpath.txt").get().asFile
62
+ def paths = [android.bootClasspath.first(),
63
+ tasks.named("compileDebugUnitTestKotlin").get().destinationDirectory.get().asFile,
64
+ tasks.named("compileDebugKotlin").get().destinationDirectory.get().asFile,
65
+ tasks.named("compileDebugJavaWithJavac").get().destinationDirectory.get().asFile]
66
+ paths.addAll(configurations.debugCompileClasspath.files)
67
+ manifest.text = paths.collect { it.absolutePath }.join("\n")
68
+ def platforms = [android.defaultConfig.minSdkVersion.apiLevel, 30].unique().collect {
69
+ new File(android.sdkDirectory, "platforms/android-${it}/android.jar")
70
+ }
71
+ platforms.each {
72
+ if (!it.isFile()) throw new GradleException("Install compatibility platform: sdkmanager 'platforms;${it.parentFile.name}'")
73
+ }
74
+ args = [manifest.absolutePath, layout.buildDirectory.dir("compatibility/dependencies").get().asFile.absolutePath] + platforms*.absolutePath
75
+ }
76
+ }
77
+
78
+ tasks.named("check").configure { dependsOn "checkModuleApiCompatibility" }
@@ -0,0 +1,215 @@
1
+ import java.io.*;
2
+ import java.net.*;
3
+ import java.nio.file.*;
4
+ import java.util.*;
5
+ import java.util.jar.JarFile;
6
+ import java.util.regex.Pattern;
7
+ import java.util.zip.ZipFile;
8
+
9
+ /** Runs without Android initialization: RN reflects private and synthetic members too. */
10
+ public final class ModuleApiCompatibility {
11
+ private static final Set<String> ROOTS = Set.of(
12
+ "com/facebook/react/bridge/NativeModule", "com/facebook/react/uimanager/ViewManager");
13
+ private static final Pattern TYPE = Pattern.compile("L([^;]+);");
14
+
15
+ record Member(String name, String descriptor) {}
16
+ record ClassInfo(String name, List<String> parents, List<Member> members) {}
17
+
18
+ private static ClassInfo readClass(InputStream stream) throws IOException {
19
+ try (var in = new DataInputStream(stream)) {
20
+ if (in.readInt() != 0xcafebabe) throw new IOException("Not a class file");
21
+ in.skipNBytes(4);
22
+ Object[] pool = new Object[in.readUnsignedShort()];
23
+ for (int i = 1; i < pool.length; i++) {
24
+ switch (in.readUnsignedByte()) {
25
+ case 1 -> pool[i] = in.readUTF();
26
+ case 7 -> pool[i] = in.readUnsignedShort();
27
+ case 3, 4, 9, 10, 11, 12, 17, 18 -> in.skipNBytes(4);
28
+ case 5, 6 -> { in.skipNBytes(8); i++; }
29
+ case 8, 16, 19, 20 -> in.skipNBytes(2);
30
+ case 15 -> in.skipNBytes(3);
31
+ default -> throw new IOException("Unknown constant pool entry");
32
+ }
33
+ }
34
+ in.readUnsignedShort();
35
+ String name = (String) pool[(int) pool[in.readUnsignedShort()]];
36
+ var parents = new ArrayList<String>();
37
+ int superclass = in.readUnsignedShort();
38
+ if (superclass != 0) parents.add((String) pool[(int) pool[superclass]]);
39
+ int interfaces = in.readUnsignedShort();
40
+ for (int i = 0; i < interfaces; i++) {
41
+ parents.add((String) pool[(int) pool[in.readUnsignedShort()]]);
42
+ }
43
+ var members = new ArrayList<Member>();
44
+ // Both field_info and method_info have the same layout.
45
+ for (int kind = 0; kind < 2; kind++) {
46
+ int count = in.readUnsignedShort();
47
+ for (int i = 0; i < count; i++) {
48
+ in.readUnsignedShort();
49
+ members.add(new Member((String) pool[in.readUnsignedShort()],
50
+ (String) pool[in.readUnsignedShort()]));
51
+ int attributes = in.readUnsignedShort();
52
+ for (int j = 0; j < attributes; j++) {
53
+ in.readUnsignedShort();
54
+ in.skipNBytes(Integer.toUnsignedLong(in.readInt()));
55
+ }
56
+ }
57
+ }
58
+ return new ClassInfo(name, parents, members);
59
+ }
60
+ }
61
+
62
+ private static boolean reflected(String name, ClassLoader classes, Map<String, Boolean> cache)
63
+ throws IOException {
64
+ if (ROOTS.contains(name)) return true;
65
+ if (cache.containsKey(name)) return cache.get(name);
66
+ var stream = classes.getResourceAsStream(name + ".class");
67
+ if (stream == null) throw new IOException("Missing hierarchy class: " + name);
68
+ var info = readClass(stream);
69
+ for (String parent : info.parents()) {
70
+ if (reflected(parent, classes, cache)) {
71
+ cache.put(name, true);
72
+ return true;
73
+ }
74
+ }
75
+ cache.put(name, false);
76
+ return false;
77
+ }
78
+
79
+ private static Set<String> platformTypes(Path jar) throws IOException {
80
+ try (var zip = new JarFile(jar.toFile())) {
81
+ var types = new HashSet<String>();
82
+ zip.stream().filter(e -> e.getName().endsWith(".class"))
83
+ .forEach(e -> types.add(e.getName().replaceFirst("\\.class$", "")));
84
+ return types;
85
+ }
86
+ }
87
+
88
+ private static List<String> check(ClassInfo info, ClassLoader runtime,
89
+ Set<String> compileTypes, Set<String> supportedTypes) throws ClassNotFoundException {
90
+ var errors = new ArrayList<String>();
91
+ for (var member : info.members()) {
92
+ var types = TYPE.matcher(member.descriptor());
93
+ while (types.find()) {
94
+ String type = types.group(1);
95
+ // Also covers Java platform types that the host JDK could otherwise mask.
96
+ if (compileTypes.contains(type) && !supportedTypes.contains(type)) {
97
+ errors.add(member.name() + member.descriptor() + " missing " + type);
98
+ }
99
+ }
100
+ }
101
+ try {
102
+ Class<?> module = Class.forName(info.name().replace('/', '.'), false, runtime);
103
+ try { module.getDeclaredMethods(); }
104
+ catch (LinkageError e) { errors.add("getDeclaredMethods: " + e); }
105
+ try { module.getDeclaredFields(); }
106
+ catch (LinkageError e) { errors.add("getDeclaredFields: " + e); }
107
+ } catch (LinkageError e) {
108
+ errors.add("class loading: " + e);
109
+ }
110
+ return errors;
111
+ }
112
+
113
+ private static List<ClassInfo> classes(Path directory) throws IOException {
114
+ try (var files = Files.walk(directory)) {
115
+ var result = new ArrayList<ClassInfo>();
116
+ for (Path file : files.filter(p -> p.toString().endsWith(".class")).sorted().toList()) {
117
+ result.add(readClass(Files.newInputStream(file)));
118
+ }
119
+ return result;
120
+ }
121
+ }
122
+
123
+ private static URLClassLoader loader(List<Path> paths) throws MalformedURLException {
124
+ var urls = new ArrayList<URL>();
125
+ for (Path path : paths) urls.add(path.toUri().toURL());
126
+ return new URLClassLoader(urls.toArray(URL[]::new), ClassLoader.getPlatformClassLoader());
127
+ }
128
+
129
+ public static void main(String[] args) throws Exception {
130
+ // Gradle writes paths one per line, avoiding command-line length limits.
131
+ List<Path> paths = Files.readAllLines(Path.of(args[0])).stream().map(Path::of).toList();
132
+ Path compilePlatform = paths.get(0);
133
+ Path fixtureDirectory = paths.get(1);
134
+ List<Path> moduleDirectories = paths.subList(2, 4);
135
+ var dependencies = new ArrayList<Path>();
136
+ Path extracted = Path.of(args[1]);
137
+ Files.createDirectories(extracted);
138
+ for (Path path : paths.subList(4, paths.size())) {
139
+ if (!path.toString().endsWith(".aar")) { dependencies.add(path); continue; }
140
+ try (var aar = new ZipFile(path.toFile())) {
141
+ for (var entry : aar.stream().filter(e -> e.getName().endsWith(".jar")).toList()) {
142
+ Path jar = extracted.resolve(dependencies.size() + ".jar");
143
+ try (var stream = aar.getInputStream(entry)) {
144
+ Files.copy(stream, jar, StandardCopyOption.REPLACE_EXISTING);
145
+ }
146
+ dependencies.add(jar);
147
+ }
148
+ }
149
+ }
150
+ var ownClasses = new ArrayList<ClassInfo>();
151
+ for (Path directory : moduleDirectories) {
152
+ if (Files.isDirectory(directory)) ownClasses.addAll(classes(directory));
153
+ }
154
+ if (ownClasses.isEmpty()) throw new AssertionError("No compiled SDK classes");
155
+ var classpath = new ArrayList<Path>(moduleDirectories);
156
+ classpath.add(fixtureDirectory);
157
+ classpath.addAll(dependencies);
158
+ var hierarchyPath = new ArrayList<Path>(classpath);
159
+ hierarchyPath.add(compilePlatform);
160
+ Set<String> compileTypes = platformTypes(compilePlatform);
161
+ try (var hierarchy = loader(hierarchyPath)) {
162
+ var cache = new HashMap<String, Boolean>();
163
+ var modules = new ArrayList<ClassInfo>();
164
+ for (var info : ownClasses) {
165
+ if (reflected(info.name(), hierarchy, cache)) modules.add(info);
166
+ }
167
+ if (modules.isEmpty()) throw new AssertionError("No React Native modules/view managers");
168
+ var fixtures = classes(fixtureDirectory);
169
+ var expected = Map.of(
170
+ "OriginalListenerModule", "AudioManager$OnCommunicationDeviceChangedListener",
171
+ "FactoryInferredModule", "AudioManager$OnCommunicationDeviceChangedListener",
172
+ "ModeListenerViewManager", "AudioManager$OnModeChangedListener");
173
+ int failures = 0;
174
+ for (int index = 2; index < args.length; index++) {
175
+ Path platform = Path.of(args[index]);
176
+ Set<String> supportedTypes = platformTypes(platform);
177
+ var runtimePath = new ArrayList<Path>();
178
+ runtimePath.add(platform);
179
+ runtimePath.addAll(classpath);
180
+ try (var runtime = loader(runtimePath)) {
181
+ for (var module : modules) {
182
+ var errors = check(module, runtime, compileTypes, supportedTypes);
183
+ for (String error : errors) System.err.println(platform + ": " + module.name() + ": " + error);
184
+ if (!errors.isEmpty()) failures++;
185
+ }
186
+ int rejected = 0;
187
+ for (var fixture : fixtures) {
188
+ String simpleName = fixture.name().substring(fixture.name().lastIndexOf('/') + 1);
189
+ if (!expected.containsKey(simpleName)) continue;
190
+ if (!reflected(fixture.name(), hierarchy, cache)) {
191
+ throw new AssertionError("Fixture is not a module/view manager: " + simpleName);
192
+ }
193
+ var errors = check(fixture, runtime, compileTypes, supportedTypes);
194
+ String missing = expected.get(simpleName);
195
+ if (supportedTypes.contains("android/media/" + missing)) {
196
+ if (!errors.isEmpty()) throw new AssertionError("Supported fixture failed: " + simpleName + ": " + errors);
197
+ rejected++;
198
+ System.out.println("EXPECTED PASS " + platform.getParent().getFileName() + " " + simpleName);
199
+ continue;
200
+ }
201
+ if (errors.stream().noneMatch(e -> e.contains("missing android/media/" + missing))
202
+ || errors.stream().noneMatch(e -> e.contains("getDeclaredMethods: java.lang.NoClassDefFoundError"))) {
203
+ throw new AssertionError("Unsafe fixture escaped: " + simpleName + ": " + errors);
204
+ }
205
+ rejected++;
206
+ System.out.println("EXPECTED FAIL " + platform.getParent().getFileName() + " " + simpleName + ": " + errors);
207
+ }
208
+ if (rejected != expected.size()) throw new AssertionError("Missing negative fixtures");
209
+ System.out.println("Checked " + modules.size() + " SDK modules/view managers against " + platform);
210
+ }
211
+ }
212
+ if (args.length < 3 || failures != 0) throw new AssertionError("Incompatible SDK classes: " + failures);
213
+ }
214
+ }
215
+ }
@@ -31,8 +31,8 @@ class ChalkAudioRouteModule(
31
31
  override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) = emit()
32
32
  override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) = emit()
33
33
  }
34
- // Created only on Android 12 and later, where the listener type exists.
35
- private val communicationListener by lazy { AudioManager.OnCommunicationDeviceChangedListener { emit() } }
34
+ // React Native reflects private methods too, so their types must exist on every supported API.
35
+ private var communicationListener: AutoCloseable? = null
36
36
 
37
37
  override fun getName(): String = "ChalkAudioRouteModule"
38
38
 
@@ -44,7 +44,7 @@ class ChalkAudioRouteModule(
44
44
  listening = true
45
45
  audioManager.registerAudioDeviceCallback(deviceCallback, handler)
46
46
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
47
- audioManager.addOnCommunicationDeviceChangedListener(reactContext.mainExecutor, communicationListener)
47
+ communicationListener = CommunicationDeviceListener(audioManager, reactContext.mainExecutor) { emit() }
48
48
  }
49
49
  }
50
50
 
@@ -56,9 +56,8 @@ class ChalkAudioRouteModule(
56
56
  override fun invalidate() {
57
57
  if (listening) {
58
58
  audioManager.unregisterAudioDeviceCallback(deviceCallback)
59
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
60
- audioManager.removeOnCommunicationDeviceChangedListener(communicationListener)
61
- }
59
+ communicationListener?.close()
60
+ communicationListener = null
62
61
  }
63
62
  super.invalidate()
64
63
  }
@@ -0,0 +1,23 @@
1
+ package ai.q9labs.chalk.reactnative
2
+
3
+ import android.media.AudioManager
4
+ import android.os.Build
5
+ import androidx.annotation.RequiresApi
6
+ import java.util.concurrent.Executor
7
+
8
+ @RequiresApi(Build.VERSION_CODES.S)
9
+ internal class CommunicationDeviceListener(
10
+ private val audioManager: AudioManager,
11
+ executor: Executor,
12
+ onChanged: () -> Unit,
13
+ ) : AutoCloseable {
14
+ private val listener = AudioManager.OnCommunicationDeviceChangedListener { onChanged() }
15
+
16
+ init {
17
+ audioManager.addOnCommunicationDeviceChangedListener(executor, listener)
18
+ }
19
+
20
+ override fun close() {
21
+ audioManager.removeOnCommunicationDeviceChangedListener(listener)
22
+ }
23
+ }
@@ -0,0 +1,29 @@
1
+ package ai.q9labs.chalk.compatibility
2
+
3
+ import android.media.AudioManager
4
+ import android.view.View
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
7
+ import com.facebook.react.uimanager.SimpleViewManager
8
+ import com.facebook.react.uimanager.ThemedReactContext
9
+
10
+ // The original crash: a lazy backing field is safe, but its generated getter is not.
11
+ class OriginalListenerModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
12
+ private val communicationListener by lazy { AudioManager.OnCommunicationDeviceChangedListener { } }
13
+ override fun getName() = "OriginalListenerModule"
14
+ }
15
+
16
+ private fun makeListener() = AudioManager.OnCommunicationDeviceChangedListener { }
17
+
18
+ // No newer Android type appears in this module's source.
19
+ class FactoryInferredModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
20
+ private val listener by lazy { makeListener() }
21
+ override fun getName() = "FactoryInferredModule"
22
+ }
23
+
24
+ // Exercise view-manager discovery and a different API-31 listener.
25
+ class ModeListenerViewManager : SimpleViewManager<View>() {
26
+ private val listener by lazy { AudioManager.OnModeChangedListener { } }
27
+ override fun getName() = "ModeListenerViewManager"
28
+ override fun createViewInstance(context: ThemedReactContext) = View(context)
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q9labsai/chalk-react-native",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -102,9 +102,9 @@
102
102
  "registry": "https://registry.npmjs.org/"
103
103
  },
104
104
  "dependencies": {
105
- "@q9labsai/chalk-client": "^5.0.0",
106
- "@q9labsai/chalk-whiteboard": "^5.0.0",
107
- "@q9labsai/facehash": "^5.0.0",
105
+ "@q9labsai/chalk-client": "^5.0.1",
106
+ "@q9labsai/chalk-whiteboard": "^5.0.1",
107
+ "@q9labsai/facehash": "^5.0.1",
108
108
  "uqr": "^0.1.3"
109
109
  },
110
110
  "devDependencies": {