linkAndroid function

void linkAndroid(
  1. String pluginName,
  2. List<String> moduleLibs, {
  3. String baseDir = '.',
  4. List<ModuleInfo>? moduleInfos,
})

Configures android/build.gradle (or .kts) so the generated Kotlin bridge files in lib/src/generated/kotlin/ are compiled as part of the Android build.

Without the kotlin.srcDirs entry, all .bridge.g.kt files are generated but never compiled — causing "Unresolved reference: XxxJniBridge" errors at build time.

Implementation

void linkAndroid(
  String pluginName,
  List<String> moduleLibs, {
  String baseDir = '.',
  List<ModuleInfo>? moduleInfos,
}) {
  File? buildGradle;
  for (final candidate in [
    File(p.join(baseDir, 'android', 'build.gradle')),
    File(p.join(baseDir, 'android', 'build.gradle.kts')),
  ]) {
    if (candidate.existsSync()) {
      buildGradle = candidate;
      break;
    }
  }
  if (buildGradle == null) return;

  var content = buildGradle.readAsStringSync();
  bool modified = false;
  final isKts = buildGradle.path.endsWith('.kts');

  // 0. Upgrade old-style `apply plugin: "kotlin-android"` to modern plugins{} DSL.
  //    The legacy `buildscript {}` + `apply plugin` approach fails in modern AGP
  //    because `kotlin-android` alias is not resolvable without the classpath in
  //    the consuming app's settings.gradle. Modern Flutter apps use `plugins {}`.
  if (content.contains('apply plugin: "kotlin-android"') ||
      content.contains("apply plugin: 'kotlin-android'")) {
    // Remove the entire buildscript block if present.
    content = content.replaceAll(
      RegExp(r'\bbuildscript\s*\{[^}]*\{[^}]*\}[^}]*\}\s*\n?', dotAll: true),
      '',
    );
    // Remove rootProject.allprojects block.
    content = content.replaceAll(
      RegExp(r'\brootProject\.allprojects\s*\{[^}]*\}\s*\n?', dotAll: true),
      '',
    );
    // Replace apply plugin lines with plugins{} block.
    content = content.replaceAll(
      RegExp(r"apply plugin:\s*'com\.android\.library'\s*\n?"),
      '',
    );
    content = content.replaceAll(
      RegExp(r'apply plugin:\s*"com\.android\.library"\s*\n?'),
      '',
    );
    content = content.replaceAll(
      RegExp(r"apply plugin:\s*'kotlin-android'\s*\n?"),
      '',
    );
    content = content.replaceAll(
      RegExp(r'apply plugin:\s*"kotlin-android"\s*\n?'),
      '',
    );
    // Insert plugins{} block at the very TOP of the file (Gradle requires it
    // before any other statements including group/version assignments).
    if (!content.contains('plugins {') && !content.contains('plugins{')) {
      // Remove group/version from their current position (they'll move after plugins{}).
      final groupVersionMatch = RegExp(
        r'^group\s*=.+\nversion\s*=.+\n',
        multiLine: true,
      ).firstMatch(content);
      String groupVersionBlock = '';
      if (groupVersionMatch != null) {
        groupVersionBlock = groupVersionMatch.group(0)!;
        content = content.replaceFirst(groupVersionBlock, '');
      }
      content =
          'plugins {\n    id "com.android.library"\n    id "org.jetbrains.kotlin.android"\n}\n\n${groupVersionBlock.trim().isEmpty ? "" : "${groupVersionBlock.trim()}\n\n"}${content.trimLeft()}';
    }
    // Fix ndkVersion = android.ndkVersion → hardcoded version for standalone builds.
    content = content.replaceAll(
      'ndkVersion = android.ndkVersion',
      'ndkVersion = "27.0.12077973"',
    );
    // Collapse sequences of 3+ blank lines to a single blank line (cosmetic cleanup).
    content = content.replaceAll(RegExp(r'\n{3,}'), '\n\n');
    modified = true;
  }

  final srcDirsLine = isKts
      ? r'            kotlin.srcDirs += setOf("${project.projectDir}/../lib/src/generated/kotlin")'
      : r'            kotlin.srcDirs += "${project.projectDir}/../lib/src/generated/kotlin"';

  // 1. Ensure kotlin.srcDirs for generated Kotlin bridges.
  //    .bridge.g.kt files live in lib/src/generated/kotlin/ — Gradle must see
  //    that directory as a Kotlin source root or the JNI bridge classes won't compile.
  //    Note: add to kotlin.srcDirs ONLY, NOT java.srcDirs — in AGP 8.x, routing
  //    .kt through the Java compiler path causes "Unresolved reference: XxxJniBridge".
  if (!content.contains('generated/kotlin')) {
    final sourceSetsMatch = RegExp(r'\bsourceSets\s*\{').firstMatch(content);
    if (sourceSetsMatch != null) {
      // sourceSets block exists — look for main {} inside it.
      final afterSourceSets = content.substring(sourceSetsMatch.end);
      final mainInBlock = RegExp(r'\bmain\s*\{').firstMatch(afterSourceSets);
      if (mainInBlock != null) {
        final mainAbsStart = sourceSetsMatch.end + mainInBlock.start;
        // Find the { of main {} and then its matching }
        final openBrace = content.indexOf(
          '{',
          mainAbsStart + mainInBlock.group(0)!.length - 1,
        );
        if (openBrace >= 0) {
          final mainClose = _findBlockEnd(content, openBrace);
          if (mainClose > 0) {
            content = content.replaceRange(
              mainClose,
              mainClose,
              '\n$srcDirsLine\n        ',
            );
            modified = true;
          }
        }
      } else {
        // sourceSets exists but no main {} — add main {} before sourceSets closing brace
        final sourceSetsClose = _findBlockEnd(content, sourceSetsMatch.end - 1);
        if (sourceSetsClose > 0) {
          content = content.replaceRange(
            sourceSetsClose,
            sourceSetsClose,
            '    main {\n$srcDirsLine\n        }\n    ',
          );
          modified = true;
        }
      }
    } else {
      // No sourceSets block — inject one inside android {}
      final androidMatch = RegExp(r'\bandroid\s*\{').firstMatch(content);
      if (androidMatch != null) {
        content = content.replaceRange(
          androidMatch.end,
          androidMatch.end,
          '\n    sourceSets {\n        main {\n$srcDirsLine\n        }\n    }',
        );
      } else {
        content +=
            '\nandroid {\n    sourceSets {\n        main {\n$srcDirsLine\n        }\n    }\n}\n';
      }
      modified = true;
    }
  }

  // 2. Ensure kotlinOptions { jvmTarget = "17" } for correct bytecode target.
  if (!content.contains('kotlinOptions')) {
    final androidMatch = RegExp(r'\bandroid\s*\{').firstMatch(content);
    if (androidMatch != null) {
      content = content.replaceRange(
        androidMatch.end,
        androidMatch.end,
        '\n    kotlinOptions { jvmTarget = "17" }',
      );
      modified = true;
    }
  }

  // 3. Ensure kotlinx-coroutines (required for generated Kotlin suspend bridge functions).
  if (!content.contains('kotlinx-coroutines')) {
    final depsMatch = RegExp(r'\bdependencies\s*\{').firstMatch(content);
    if (depsMatch != null) {
      content = content.replaceRange(
        depsMatch.end,
        depsMatch.end,
        '\n    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"\n    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"',
      );
    } else {
      content +=
          '\ndependencies {\n    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"\n    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"\n}\n';
    }
    modified = true;
  }

  if (modified) buildGradle.writeAsStringSync(content);
}