linkAndroidConsumerRules function

void linkAndroidConsumerRules(
  1. List<Map<String, String>> kotlinModules, {
  2. String baseDir = '.',
})

Writes/patches android/consumer-rules.pro with the R8/ProGuard keep rules every Kotlin-on-Android Nitro module needs.

The generated JNI bridge (nitro.<lib>_module.<Module>JniBridge) and the hand-written impl (under the plugin's Android namespace) are reached from C++ via FindClass + GetStaticMethodID by exact name AND signature — R8 has no static-analysis visibility into that, so without these rules it may rename, remove, or (in "full mode") mis-optimize them.

Uses includedescriptorclasses — not a stronger form of a plain -keep, a DIFFERENT protection: plain -keep class X { *; } only protects a member from removal/renaming, not the parameter/return TYPES referenced in its signature. For methods JNI calls by exact signature (every _call trampoline, several of which take many long/data-class params for suspend methods), an altered parameter type produces a VerifyError at the exact call site — a real, previously-hit crash ("VerifyError ... Long (Low Half)"), not a hypothetical one. This is ProGuard's own documented remedy for native/JNI-called methods.

Idempotent and additive: only touches the marked block (creating it if absent), never the rest of the file, so a plugin author's own rules for unrelated dependencies survive every nitrogen link run. Re-derives the block's content from the CURRENT module list each time, so e.g. adding a second Nitro module to the plugin updates the keep rules automatically.

Implementation

void linkAndroidConsumerRules(
  List<Map<String, String>> kotlinModules, {
  String baseDir = '.',
}) {
  if (kotlinModules.isEmpty) return;
  final androidDir = Directory(p.join(baseDir, 'android'));
  if (!androidDir.existsSync()) return;

  String? namespace;
  for (final candidate in [
    File(p.join(baseDir, 'android', 'build.gradle')),
    File(p.join(baseDir, 'android', 'build.gradle.kts')),
  ]) {
    if (!candidate.existsSync()) continue;
    final m = RegExp(r'''namespace\s*=?\s*["']([^"']+)["']''').firstMatch(candidate.readAsStringSync());
    if (m != null) namespace = m.group(1);
    break;
  }

  final bridgePackages = kotlinModules.map((m) => 'nitro.${(m['lib'] ?? '').replaceAll('-', '_')}_module').toSet().toList()..sort();

  final block = StringBuffer()
    ..writeln(_nitroConsumerRulesBeginMarker)
    ..writeln('# The generated Nitro JNI bridge and its hand-written impl are reached')
    ..writeln('# from C++ via FindClass + GetStaticMethodID by exact name and signature.')
    ..writeln('# includedescriptorclasses additionally keeps the parameter/return types')
    ..writeln('# referenced in those signatures — without it R8 full mode can still')
    ..writeln('# rename/merge them, producing a VerifyError at the JNI call site even')
    ..writeln('# though the method itself is "kept". See linkAndroidConsumerRules in')
    ..writeln('# nitrogen_cli for the full explanation.');
  for (final pkg in bridgePackages) {
    block.writeln('-keep,includedescriptorclasses class $pkg.** {');
    block.writeln('    *;');
    block.writeln('}');
  }
  if (namespace != null) {
    block.writeln('-keep,includedescriptorclasses class $namespace.** {');
    block.writeln('    *;');
    block.writeln('}');
  }
  block
    ..writeln()
    ..writeln('# JNI resolves native methods (Kotlin `external fun`) by exact name AND')
    ..writeln('# signature — includedescriptorclasses protects their parameter/return')
    ..writeln('# types too, not just their names.')
    ..writeln('-keepclasseswithmembernames,includedescriptorclasses class * {')
    ..writeln('    native <methods>;')
    ..writeln('}')
    ..write(_nitroConsumerRulesEndMarker);

  final rulesFile = File(p.join(androidDir.path, 'consumer-rules.pro'));
  final existing = rulesFile.existsSync() ? rulesFile.readAsStringSync() : '';
  final markerPattern = RegExp(
    '${RegExp.escape(_nitroConsumerRulesBeginMarker)}.*?${RegExp.escape(_nitroConsumerRulesEndMarker)}',
    dotAll: true,
  );
  final String updated;
  if (markerPattern.hasMatch(existing)) {
    updated = existing.replaceFirst(markerPattern, block.toString());
  } else if (existing.trim().isEmpty) {
    updated = '$block\n';
  } else {
    updated = '${existing.trimRight()}\n\n$block\n';
  }
  if (updated != existing) rulesFile.writeAsStringSync(updated);
}