[jnigen] Support renaming interface mixins (#3528)
diff --git a/pkgs/jnigen/CHANGELOG.md b/pkgs/jnigen/CHANGELOG.md
index c84c5e9..8a704fe 100644
--- a/pkgs/jnigen/CHANGELOG.md
+++ b/pkgs/jnigen/CHANGELOG.md
@@ -14,6 +14,7 @@
   - Flip `isExcluded` to `isIncluded`.
   - Make a bunch of nullable fields non-null, if null was functionally
     identical to a default value.
+  - Allow interface mixin names to be customized using the visitor API.
 
 ## 0.17.0
 
diff --git a/pkgs/jnigen/lib/src/bindings/dart_generator.dart b/pkgs/jnigen/lib/src/bindings/dart_generator.dart
index 58969b4..32b6502 100644
--- a/pkgs/jnigen/lib/src/bindings/dart_generator.dart
+++ b/pkgs/jnigen/lib/src/bindings/dart_generator.dart
@@ -449,7 +449,6 @@
       ),
     );
     final implementsClause = {superName, ...interfaces}.join(', ');
-    final implClassName = '\$$name';
     final typeParamsDef = node.allTypeParams
         .accept(const _TypeParamDef())
         .join(', ')
@@ -493,9 +492,10 @@
     node.compareTo?.accept(_ComparatorGenerator(resolver, instanceSink));
 
     if (node.declKind == DeclKind.interfaceKind) {
+      final interfaceMixinName = node.finalInterfaceMixinName;
       s.write('''
   /// Maps a specific port to the implemented interface.
-  static final $_core.Map<$_core.int, $implClassName> _\$impls = {};
+  static final $_core.Map<$_core.int, $interfaceMixinName> _\$impls = {};
 ''');
       s.write('''
   static $_jni.JObjectPtr _\$invoke(
@@ -540,7 +540,7 @@
 
   static void implementIn$typeParamsDef(
     $_jni.JImplementer implementer,
-    $implClassName$typeParamsCall \$impl,
+    $interfaceMixinName$typeParamsCall \$impl,
   ) {
     late final $_jni.RawReceivePort \$p;
     \$p = $_jni.RawReceivePort((\$m) {
@@ -564,7 +564,7 @@
       final interfaceAsyncMethod = _InterfaceIfAsyncMethod(
         resolver,
         s,
-        implClassName: implClassName,
+        implClassName: interfaceMixinName,
       );
       for (final method in node.methods) {
         method.accept(interfaceAsyncMethod);
@@ -577,7 +577,7 @@
   }
 
   factory $name.implement(
-    $implClassName$typeParamsCall \$impl,
+    $interfaceMixinName$typeParamsCall \$impl,
   ) {
     final \$i = $_jni.JImplementer();
     implementIn(\$i, \$impl);
@@ -608,16 +608,17 @@
     // Abstract and concrete Impl class definition.
     // Used for interface implementation.
     if (node.declKind == DeclKind.interfaceKind) {
+      final interfaceMixinName = node.finalInterfaceMixinName;
       // Abstract Impl class.
       final abstractFactoryArgs = node.methods
           .accept(_AbstractImplFactoryArg(resolver))
           .join(_newLine(depth: 2))
           .encloseIfNotEmpty('{', '}');
       s.write('''
-abstract base mixin class $implClassName$typeParamsDef {
-  factory $implClassName(
+abstract base mixin class $interfaceMixinName$typeParamsDef {
+  factory $interfaceMixinName(
     $abstractFactoryArgs
-  ) = _$implClassName$typeParamsCall;
+  ) = _$interfaceMixinName$typeParamsCall;
 
 ''');
       final abstractImplMethod = _AbstractImplMethod(resolver, s);
@@ -638,8 +639,8 @@
           .encloseIfNotEmpty(' :  ', '');
       s.write('''
 
-final class _$implClassName$typeParamsDef with $implClassName$typeParamsCall {
-  _$implClassName(
+final class _$interfaceMixinName$typeParamsDef with $interfaceMixinName$typeParamsCall {
+  _$interfaceMixinName(
     $concreteCtorArgs
   )$setClosures;
 
diff --git a/pkgs/jnigen/lib/src/bindings/renamer.dart b/pkgs/jnigen/lib/src/bindings/renamer.dart
index e4e19f0..dfc1ca2 100644
--- a/pkgs/jnigen/lib/src/bindings/renamer.dart
+++ b/pkgs/jnigen/lib/src/bindings/renamer.dart
@@ -181,16 +181,23 @@
 class _ClassRenamer implements Visitor<ClassDecl, void> {
   final Config config;
   final Set<ClassDecl> renamed;
-  final Map<String, int> topLevelNameCounts = {
-    ..._definedSyms,
-    ..._reservedTopLevelNames,
-  };
+  final Map<String, Map<String, int>> topLevelNameCounts = {};
   final Map<ClassDecl, Map<String, int>> nameCounts = {};
 
   _ClassRenamer(
     this.config,
   ) : renamed = {...config.importedClasses.values};
 
+  Map<String, int> _getTopLevelNameCounts(ClassDecl node) {
+    return topLevelNameCounts.putIfAbsent(
+      node.path,
+      () => {
+        ..._definedSyms,
+        ..._reservedTopLevelNames,
+      },
+    );
+  }
+
   @override
   void visit(ClassDecl node) {
     if (renamed.contains(node)) return;
@@ -217,13 +224,25 @@
     final className =
         '$outerClassName${_preprocess(node.userDefinedName ?? node.name)}';
 
-    // When generating all the classes in a single file
-    // the names need to be unique.
-    final uniquifyName =
-        config.output.dart.structure == OutputStructure.singleFile;
-    node.finalName = uniquifyName
-        ? _renameConflict(topLevelNameCounts, className, _ElementKind.klass)
-        : className;
+    final generatedFileNameCounts = _getTopLevelNameCounts(node);
+
+    node.finalName = _renameConflict(
+      generatedFileNameCounts,
+      className,
+      _ElementKind.klass,
+    );
+
+    if (node.declKind == DeclKind.interfaceKind) {
+      final interfaceMixinName = node.userDefinedInterfaceMixinName == null
+          ? '\$${node.finalName}'
+          : _preprocess(node.userDefinedInterfaceMixinName!);
+
+      node.finalInterfaceMixinName = _renameConflict(
+        generatedFileNameCounts,
+        interfaceMixinName,
+        _ElementKind.klass,
+      );
+    }
 
     if (node.userDefinedName == null ||
         node.userDefinedName == node.finalName) {
@@ -238,7 +257,7 @@
     // method will be renamed.
     final fieldRenamer = _FieldRenamer(
       config,
-      uniquifyName && node.isTopLevel ? topLevelNameCounts : nameCounts[node]!,
+      node.isTopLevel ? generatedFileNameCounts : nameCounts[node]!,
     );
     for (final field in node.fields) {
       field.accept(fieldRenamer);
@@ -246,7 +265,7 @@
 
     final methodRenamer = _MethodRenamer(
       config,
-      uniquifyName && node.isTopLevel ? topLevelNameCounts : nameCounts[node]!,
+      node.isTopLevel ? generatedFileNameCounts : nameCounts[node]!,
       node.declKind == DeclKind.interfaceKind,
     );
     for (final method in node.methods) {
diff --git a/pkgs/jnigen/lib/src/elements/elements.dart b/pkgs/jnigen/lib/src/elements/elements.dart
index 6fc037f..5250d1d 100644
--- a/pkgs/jnigen/lib/src/elements/elements.dart
+++ b/pkgs/jnigen/lib/src/elements/elements.dart
@@ -108,6 +108,9 @@
   @JsonKey(includeFromJson: false)
   String? userDefinedName;
 
+  @JsonKey(includeFromJson: false)
+  String? userDefinedInterfaceMixinName;
+
   @override
   final Set<String> modifiers;
 
@@ -149,6 +152,9 @@
   @override
   late String finalName;
 
+  @JsonKey(includeFromJson: false)
+  late String finalInterfaceMixinName;
+
   /// Name of the type class.
   @JsonKey(includeFromJson: false)
   String get typeClassName => '\$$finalName\$Type\$';
diff --git a/pkgs/jnigen/lib/src/elements/j_elements.dart b/pkgs/jnigen/lib/src/elements/j_elements.dart
index 49ccced..31d9dbd 100644
--- a/pkgs/jnigen/lib/src/elements/j_elements.dart
+++ b/pkgs/jnigen/lib/src/elements/j_elements.dart
@@ -110,6 +110,15 @@
   /// The original name of the class in Java.
   String get originalName => _classDecl.name;
 
+  /// The custom name of the mixin generated for implementing this Java
+  /// interface
+  ///
+  /// If null, the default generated name is used.
+  String? get interfaceMixinName => _classDecl.userDefinedInterfaceMixinName;
+
+  set interfaceMixinName(String? newName) =>
+      _classDecl.userDefinedInterfaceMixinName = newName;
+
   @override
   void accept(Visitor visitor) {
     visitor.visitClass(this);
diff --git a/pkgs/jnigen/test/renamer_test.dart b/pkgs/jnigen/test/renamer_test.dart
index 8ebbee6..4fe63f2 100644
--- a/pkgs/jnigen/test/renamer_test.dart
+++ b/pkgs/jnigen/test/renamer_test.dart
@@ -21,13 +21,18 @@
       }).toList();
 }
 
-Future<void> rename(Classes classes) async {
+Future<void> rename(
+  Classes classes, {
+  OutputStructure structure = OutputStructure.singleFile,
+}) async {
   final config = Config(
     input: Input(classes: []),
     output: Output(
       dart: DartCodeOutput(
-        path: Uri.file('test.dart'),
-        structure: OutputStructure.singleFile,
+        path: structure == OutputStructure.singleFile
+            ? Uri.file('test.dart')
+            : Uri.directory('test_output/'),
+        structure: structure,
       ),
     ),
   );
@@ -303,6 +308,67 @@
     expect(classRenamedMethods, [r'implement', r'implementIn']);
   });
 
+  test('Interface mixin names', () async {
+    final classes = Classes({
+      'Foo': ClassDecl(
+        binaryName: 'Foo',
+        declKind: DeclKind.interfaceKind,
+        superclass: DeclaredType.object,
+      ),
+      'Bar': ClassDecl(
+        binaryName: 'Bar',
+        declKind: DeclKind.interfaceKind,
+        superclass: DeclaredType.object,
+      )..userDefinedInterfaceMixinName = 'Foo',
+      'Baz': ClassDecl(
+        binaryName: 'Baz',
+        declKind: DeclKind.interfaceKind,
+        superclass: DeclaredType.object,
+      )..userDefinedInterfaceMixinName = 'class',
+    });
+
+    await rename(classes);
+
+    expect(classes.decls['Foo']!.finalInterfaceMixinName, r'$Foo');
+    expect(classes.decls['Bar']!.finalInterfaceMixinName, r'Foo$1');
+    expect(classes.decls['Baz']!.finalInterfaceMixinName, r'class$');
+  });
+
+  test('Interface mixin name preprocessing', () async {
+    final classes = Classes({
+      'Foo': ClassDecl(
+        binaryName: 'Foo',
+        declKind: DeclKind.interfaceKind,
+        superclass: DeclaredType.object,
+      )..userDefinedInterfaceMixinName = r'_Foo$',
+    });
+
+    await rename(classes);
+
+    expect(
+      classes.decls['Foo']!.finalInterfaceMixinName,
+      r'$_Foo$$',
+    );
+  });
+
+  test('Interface mixin name conflicts in package structure', () async {
+    final classes = Classes({
+      'Foo': ClassDecl(
+        binaryName: 'Foo',
+        declKind: DeclKind.interfaceKind,
+        superclass: DeclaredType.object,
+      )..userDefinedInterfaceMixinName = 'Foo',
+    });
+
+    await rename(
+      classes,
+      structure: OutputStructure.packageStructure,
+    );
+
+    expect(classes.decls['Foo']!.finalName, 'Foo');
+    expect(classes.decls['Foo']!.finalInterfaceMixinName, r'Foo$1');
+  });
+
   test('Inner classes vs classes with dollar signs', () async {
     final classes = Classes({
       'Outer': ClassDecl(
diff --git a/pkgs/jnigen/test/user_visitor_test.dart b/pkgs/jnigen/test/user_visitor_test.dart
index 583538e..3f3088c 100644
--- a/pkgs/jnigen/test/user_visitor_test.dart
+++ b/pkgs/jnigen/test/user_visitor_test.dart
@@ -2,7 +2,10 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'dart:io';
+
 import 'package:jnigen/jnigen.dart';
+import 'package:jnigen/src/bindings/dart_generator.dart';
 import 'package:jnigen/src/bindings/linker.dart';
 import 'package:jnigen/src/bindings/renamer.dart';
 import 'package:jnigen/src/elements/elements.dart' as ast;
@@ -255,4 +258,101 @@
     expect(classes.decls['y.Foo']?.methods.first.params.finalNames,
         ['Bar', 'Bar1']);
   });
+
+  test('Rename interface mixin using the user visitor', () async {
+    final classes = ast.Classes({
+      'Foo': ast.ClassDecl(
+        binaryName: 'Foo',
+        declKind: ast.DeclKind.interfaceKind,
+        superclass: ast.DeclaredType.object,
+      ),
+    });
+
+    Classes(classes).accept(
+      Visitor(
+        visitClass: (c) {
+          if (c.originalName == 'Foo') {
+            c.interfaceMixinName = 'FooInterface';
+          }
+        },
+      ),
+    );
+
+    expect(
+      classes.decls['Foo']!.userDefinedInterfaceMixinName,
+      'FooInterface',
+    );
+
+    await rename(classes);
+
+    expect(
+      classes.decls['Foo']!.finalInterfaceMixinName,
+      'FooInterface',
+    );
+  });
+
+  test('Use the renamed interface mixin in generated bindings', () async {
+    final tempDirectory = Directory.systemTemp.createTempSync(
+      'jnigen_interface_mixin_test_',
+    );
+    addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+    final output = tempDirectory.uri.resolve('bindings.dart');
+    final config = Config(
+      input: Input(classes: []),
+      output: Output(
+        dart: DartCodeOutput(
+          path: output,
+          structure: OutputStructure.singleFile,
+        ),
+      ),
+    );
+
+    final classes = ast.Classes({
+      'Foo': ast.ClassDecl(
+        binaryName: 'Foo',
+        declKind: ast.DeclKind.interfaceKind,
+        superclass: ast.DeclaredType.object,
+        methods: [
+          ast.Method(
+            name: 'run',
+            returnType: ast.PrimitiveType.fromJson({'name': 'void'}),
+          ),
+        ],
+      ),
+    });
+
+    Classes(classes).accept(
+      Visitor(
+        visitClass: (c) {
+          if (c.originalName == 'Foo') {
+            c.interfaceMixinName = 'FooInterface';
+          }
+        },
+      ),
+    );
+
+    await classes.accept(Linker(config));
+    classes.accept(Renamer(config));
+    await classes.accept(DartGenerator(config));
+
+    final content = File.fromUri(output).readAsStringSync();
+
+    expect(
+      content,
+      contains('abstract base mixin class FooInterface'),
+    );
+    expect(
+      content,
+      contains('final class _FooInterface with FooInterface'),
+    );
+    expect(
+      content,
+      contains(r'FooInterface $impl'),
+    );
+    expect(
+      content,
+      isNot(contains(r'abstract base mixin class $Foo')),
+    );
+  });
 }