[pigeon] Adds Kotlin support for Pigeon (#999)

diff --git a/packages/pigeon/CHANGELOG.md b/packages/pigeon/CHANGELOG.md
index 583a625..8d6aa74 100644
--- a/packages/pigeon/CHANGELOG.md
+++ b/packages/pigeon/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 4.2.0
+
+* Adds experimental support for Kotlin generation.
+
 ## 4.1.1
 
 * [java] Adds missing `@NonNull` annotations to some methods.
diff --git a/packages/pigeon/README.md b/packages/pigeon/README.md
index 86607f7..f277ff0 100644
--- a/packages/pigeon/README.md
+++ b/packages/pigeon/README.md
@@ -5,9 +5,10 @@
 
 ## Supported Platforms
 
-Currently Pigeon supports generating Objective-C code for usage on iOS, Java
-code for Android, and has experimental support for C++ for Windows. The
-Objective-C code is
+Currently Pigeon supports generating Objective-C and experimental Swift code
+for usage on iOS, Java and experimental Kotlin code for Android, 
+and has experimental support for C++ for Windows.
+The Objective-C code is
 [accessible to Swift](https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift)
 and the Java code is accessible to Kotlin.
 
@@ -31,18 +32,24 @@
 
 ### Flutter calling into iOS steps
 
-1) Add the generated Objective-C code to your Xcode project for compilation
+1) Add the generated Objective-C or Swift code to your Xcode project for compilation
    (e.g. `ios/Runner.xcworkspace` or `.podspec`).
 1) Implement the generated iOS protocol for handling the calls on iOS, set it up
    as the handler for the messages.
 
+**Note:** Swift code generation for iOS is experimental while we get more usage and add more
+testing. Not all features may be supported.
+
 ### Flutter calling into Android Steps
 
-1) Add the generated Java code to your `./android/app/src/main/java` directory
+1) Add the generated Java or Kotlin code to your `./android/app/src/main/java` directory
    for compilation.
-1) Implement the generated Java interface for handling the calls on Android, set
+1) Implement the generated Java or Kotlin interface for handling the calls on Android, set
    it up as the handler for the messages.
 
+**Note:** Kotlin code generation for Android is experimental while we get more usage and add more
+testing and works just with Flutter 3.3.0 or later. Not all features may be supported.
+
 ### Flutter calling into Windows Steps
 
 1) Add the generated C++ code to your `./windows` directory for compilation, and
@@ -112,6 +119,15 @@
 @end
 ```
 
+```swift
+// Swift
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter.*/
+protocol Api2Host {
+  func calculate(value: Value, completion: @escaping (Value) -> Void)
+}
+```
+
 ```java
 // Java
 public interface Result<T> {
@@ -124,6 +140,15 @@
 }
 ```
 
+```kotlin
+// Kotlin
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter.*/
+interface Api2Host {
+   fun calculate(value: Value, callback: (Value) -> Unit)
+}
+```
+
 ```c++
 // C++
 
diff --git a/packages/pigeon/lib/generator_tools.dart b/packages/pigeon/lib/generator_tools.dart
index 53a0aac..fc32d23 100644
--- a/packages/pigeon/lib/generator_tools.dart
+++ b/packages/pigeon/lib/generator_tools.dart
@@ -9,7 +9,7 @@
 import 'ast.dart';
 
 /// The current version of pigeon. This must match the version in pubspec.yaml.
-const String pigeonVersion = '4.1.1';
+const String pigeonVersion = '4.2.0';
 
 /// Read all the content from [stdin] to a String.
 String readStdin() {
diff --git a/packages/pigeon/lib/kotlin_generator.dart b/packages/pigeon/lib/kotlin_generator.dart
new file mode 100644
index 0000000..dc70534
--- /dev/null
+++ b/packages/pigeon/lib/kotlin_generator.dart
@@ -0,0 +1,655 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'ast.dart';
+import 'functional.dart';
+import 'generator_tools.dart';
+import 'pigeon_lib.dart' show TaskQueueType;
+
+/// Options that control how Kotlin code will be generated.
+class KotlinOptions {
+  /// Creates a [KotlinOptions] object
+  const KotlinOptions({
+    this.package,
+    this.copyrightHeader,
+  });
+
+  /// The package where the generated class will live.
+  final String? package;
+
+  /// A copyright header that will get prepended to generated code.
+  final Iterable<String>? copyrightHeader;
+
+  /// Creates a [KotlinOptions] from a Map representation where:
+  /// `x = KotlinOptions.fromMap(x.toMap())`.
+  static KotlinOptions fromMap(Map<String, Object> map) {
+    return KotlinOptions(
+      package: map['package'] as String?,
+      copyrightHeader: map['copyrightHeader'] as Iterable<String>?,
+    );
+  }
+
+  /// Converts a [KotlinOptions] to a Map representation where:
+  /// `x = KotlinOptions.fromMap(x.toMap())`.
+  Map<String, Object> toMap() {
+    final Map<String, Object> result = <String, Object>{
+      if (package != null) 'package': package!,
+      if (copyrightHeader != null) 'copyrightHeader': copyrightHeader!,
+    };
+    return result;
+  }
+
+  /// Overrides any non-null parameters from [options] into this to make a new
+  /// [KotlinOptions].
+  KotlinOptions merge(KotlinOptions options) {
+    return KotlinOptions.fromMap(mergeMaps(toMap(), options.toMap()));
+  }
+}
+
+/// Calculates the name of the codec that will be generated for [api].
+String _getCodecName(Api api) => '${api.name}Codec';
+
+/// Writes the codec class that will be used by [api].
+/// Example:
+/// private static class FooCodec extends StandardMessageCodec {...}
+void _writeCodec(Indent indent, Api api, Root root) {
+  final String codecName = _getCodecName(api);
+  indent.writeln('@Suppress("UNCHECKED_CAST")');
+  indent.write('private object $codecName : StandardMessageCodec() ');
+  indent.scoped('{', '}', () {
+    if (getCodecClasses(api, root).isNotEmpty) {
+      indent.write(
+          'override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? ');
+      indent.scoped('{', '}', () {
+        indent.write('return when (type) ');
+        indent.scoped('{', '}', () {
+          for (final EnumeratedClass customClass
+              in getCodecClasses(api, root)) {
+            indent.write('${customClass.enumeration}.toByte() -> ');
+            indent.scoped('{', '}', () {
+              indent.write(
+                  'return (readValue(buffer) as? Map<String, Any?>)?.let ');
+              indent.scoped('{', '}', () {
+                indent.writeln('${customClass.name}.fromMap(it)');
+              });
+            });
+          }
+          indent.writeln('else -> super.readValueOfType(type, buffer)');
+        });
+      });
+
+      indent.write(
+          'override fun writeValue(stream: ByteArrayOutputStream, value: Any?) ');
+      indent.writeScoped('{', '}', () {
+        indent.write('when (value) ');
+        indent.scoped('{', '}', () {
+          for (final EnumeratedClass customClass
+              in getCodecClasses(api, root)) {
+            indent.write('is ${customClass.name} -> ');
+            indent.scoped('{', '}', () {
+              indent.writeln('stream.write(${customClass.enumeration})');
+              indent.writeln('writeValue(stream, value.toMap())');
+            });
+          }
+          indent.writeln('else -> super.writeValue(stream, value)');
+        });
+      });
+    }
+  });
+}
+
+/// Write the kotlin code that represents a host [Api], [api].
+/// Example:
+/// interface Foo {
+///   Int add(x: Int, y: Int);
+///   companion object {
+///     fun setUp(binaryMessenger: BinaryMessenger, api: Api) {...}
+///   }
+/// }
+void _writeHostApi(Indent indent, Api api, Root root) {
+  assert(api.location == ApiLocation.host);
+
+  final String apiName = api.name;
+
+  indent.writeln(
+      '/** Generated interface from Pigeon that represents a handler of messages from Flutter. */');
+  indent.write('interface $apiName ');
+  indent.scoped('{', '}', () {
+    for (final Method method in api.methods) {
+      final List<String> argSignature = <String>[];
+      if (method.arguments.isNotEmpty) {
+        final Iterable<String> argTypes = method.arguments
+            .map((NamedType e) => _nullsafeKotlinTypeForDartType(e.type));
+        final Iterable<String> argNames =
+            method.arguments.map((NamedType e) => e.name);
+        argSignature
+            .addAll(map2(argTypes, argNames, (String argType, String argName) {
+          return '$argName: $argType';
+        }));
+      }
+
+      final String returnType = method.returnType.isVoid
+          ? ''
+          : _nullsafeKotlinTypeForDartType(method.returnType);
+      if (method.isAsynchronous) {
+        argSignature.add('callback: ($returnType) -> Unit');
+        indent.writeln('fun ${method.name}(${argSignature.join(', ')})');
+      } else if (method.returnType.isVoid) {
+        indent.writeln('fun ${method.name}(${argSignature.join(', ')})');
+      } else {
+        indent.writeln(
+            'fun ${method.name}(${argSignature.join(', ')}): $returnType');
+      }
+    }
+
+    indent.addln('');
+    indent.write('companion object ');
+    indent.scoped('{', '}', () {
+      indent.writeln('/** The codec used by $apiName. */');
+      indent.scoped('val codec: MessageCodec<Any?> by lazy {', '}', () {
+        indent.writeln(_getCodecName(api));
+      });
+      indent.writeln(
+          '/** Sets up an instance of `$apiName` to handle messages through the `binaryMessenger`. */');
+      indent.writeln('@Suppress("UNCHECKED_CAST")');
+      indent.write(
+          'fun setUp(binaryMessenger: BinaryMessenger, api: $apiName?) ');
+      indent.scoped('{', '}', () {
+        for (final Method method in api.methods) {
+          indent.write('');
+          indent.scoped('run {', '}', () {
+            String? taskQueue;
+            if (method.taskQueueType != TaskQueueType.serial) {
+              taskQueue = 'taskQueue';
+              indent.writeln(
+                  'val $taskQueue = binaryMessenger.makeBackgroundTaskQueue()');
+            }
+
+            final String channelName = makeChannelName(api, method);
+
+            indent.write(
+                'val channel = BasicMessageChannel<Any?>(binaryMessenger, "$channelName", codec');
+
+            if (taskQueue != null) {
+              indent.addln(', $taskQueue)');
+            } else {
+              indent.addln(')');
+            }
+
+            indent.write('if (api != null) ');
+            indent.scoped('{', '}', () {
+              final String messageVarName =
+                  method.arguments.isNotEmpty ? 'message' : '_';
+
+              indent.write('channel.setMessageHandler ');
+              indent.scoped('{ $messageVarName, reply ->', '}', () {
+                indent.writeln('val wrapped = hashMapOf<String, Any?>()');
+
+                indent.write('try ');
+                indent.scoped('{', '}', () {
+                  final List<String> methodArgument = <String>[];
+                  if (method.arguments.isNotEmpty) {
+                    indent.writeln('val args = message as List<Any?>');
+                    enumerate(method.arguments, (int index, NamedType arg) {
+                      final String argName = _getSafeArgumentName(index, arg);
+                      final String argIndex = 'args[$index]';
+                      indent.writeln(
+                          'val $argName = ${_castForceUnwrap(argIndex, arg.type, root)}');
+                      methodArgument.add(argName);
+                    });
+                  }
+                  final String call =
+                      'api.${method.name}(${methodArgument.join(', ')})';
+                  if (method.isAsynchronous) {
+                    indent.write('$call ');
+                    if (method.returnType.isVoid) {
+                      indent.scoped('{', '}', () {
+                        indent.writeln('reply.reply(null)');
+                      });
+                    } else {
+                      indent.scoped('{', '}', () {
+                        indent.writeln('reply.reply(wrapResult(it))');
+                      });
+                    }
+                  } else if (method.returnType.isVoid) {
+                    indent.writeln(call);
+                    indent.writeln('wrapped["${Keys.result}"] = null');
+                  } else {
+                    indent.writeln('wrapped["${Keys.result}"] = $call');
+                  }
+                }, addTrailingNewline: false);
+                indent.add(' catch (exception: Error) ');
+                indent.scoped('{', '}', () {
+                  indent.writeln(
+                      'wrapped["${Keys.error}"] = wrapError(exception)');
+                  if (method.isAsynchronous) {
+                    indent.writeln('reply.reply(wrapped)');
+                  }
+                });
+                if (!method.isAsynchronous) {
+                  indent.writeln('reply.reply(wrapped)');
+                }
+              });
+            }, addTrailingNewline: false);
+            indent.scoped(' else {', '}', () {
+              indent.writeln('channel.setMessageHandler(null)');
+            });
+          });
+        }
+      });
+    });
+  });
+}
+
+String _getArgumentName(int count, NamedType argument) =>
+    argument.name.isEmpty ? 'arg$count' : argument.name;
+
+/// Returns an argument name that can be used in a context where it is possible to collide.
+String _getSafeArgumentName(int count, NamedType argument) =>
+    '${_getArgumentName(count, argument)}Arg';
+
+/// Writes the code for a flutter [Api], [api].
+/// Example:
+/// class Foo(private val binaryMessenger: BinaryMessenger) {
+///   fun add(x: Int, y: Int, callback: (Int?) -> Unit) {...}
+/// }
+void _writeFlutterApi(Indent indent, Api api) {
+  assert(api.location == ApiLocation.flutter);
+  indent.writeln(
+      '/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */');
+  final String apiName = api.name;
+  indent.writeln('@Suppress("UNCHECKED_CAST")');
+  indent.write('class $apiName(private val binaryMessenger: BinaryMessenger) ');
+  indent.scoped('{', '}', () {
+    indent.write('companion object ');
+    indent.scoped('{', '}', () {
+      indent.writeln('/** The codec used by $apiName. */');
+      indent.write('val codec: MessageCodec<Any?> by lazy ');
+      indent.scoped('{', '}', () {
+        indent.writeln(_getCodecName(api));
+      });
+    });
+
+    for (final Method func in api.methods) {
+      final String channelName = makeChannelName(api, func);
+      final String returnType = func.returnType.isVoid
+          ? ''
+          : _nullsafeKotlinTypeForDartType(func.returnType);
+      String sendArgument;
+      if (func.arguments.isEmpty) {
+        indent.write('fun ${func.name}(callback: ($returnType) -> Unit) ');
+        sendArgument = 'null';
+      } else {
+        final Iterable<String> argTypes = func.arguments
+            .map((NamedType e) => _nullsafeKotlinTypeForDartType(e.type));
+        final Iterable<String> argNames =
+            indexMap(func.arguments, _getSafeArgumentName);
+        sendArgument = 'listOf(${argNames.join(', ')})';
+        final String argsSignature = map2(argTypes, argNames,
+            (String type, String name) => '$name: $type').join(', ');
+        if (func.returnType.isVoid) {
+          indent
+              .write('fun ${func.name}($argsSignature, callback: () -> Unit) ');
+        } else {
+          indent.write(
+              'fun ${func.name}($argsSignature, callback: ($returnType) -> Unit) ');
+        }
+      }
+      indent.scoped('{', '}', () {
+        const String channel = 'channel';
+        indent.writeln(
+            'val $channel = BasicMessageChannel<Any?>(binaryMessenger, "$channelName", codec)');
+        indent.write('$channel.send($sendArgument) ');
+        if (func.returnType.isVoid) {
+          indent.scoped('{', '}', () {
+            indent.writeln('callback()');
+          });
+        } else {
+          final String forceUnwrap = func.returnType.isNullable ? '?' : '';
+          indent.scoped('{', '}', () {
+            indent.writeln('val result = it as$forceUnwrap $returnType');
+            indent.writeln('callback(result)');
+          });
+        }
+      });
+    }
+  });
+}
+
+String _castForceUnwrap(String value, TypeDeclaration type, Root root) {
+  if (isEnum(root, type)) {
+    final String forceUnwrap = type.isNullable ? '' : '!!';
+    final String nullableConditionPrefix =
+        type.isNullable ? '$value == null ? null : ' : '';
+    return '$nullableConditionPrefix${_kotlinTypeForDartType(type)}.ofRaw($value as Int)$forceUnwrap';
+  } else {
+    final String castUnwrap = type.isNullable ? '?' : '';
+
+    // The StandardMessageCodec can give us [Integer, Long] for
+    // a Dart 'int'.  To keep things simple we just use 64bit
+    // longs in Pigeon with Kotlin.
+    if (type.baseName == 'int') {
+      return '$value.let { if (it is Int) it.toLong() else it as$castUnwrap Long }';
+    } else {
+      return '$value as$castUnwrap ${_kotlinTypeForDartType(type)}';
+    }
+  }
+}
+
+/// Converts a [List] of [TypeDeclaration]s to a comma separated [String] to be
+/// used in Kotlin code.
+String _flattenTypeArguments(List<TypeDeclaration> args) {
+  return args.map(_kotlinTypeForDartType).join(', ');
+}
+
+String _kotlinTypeForBuiltinGenericDartType(TypeDeclaration type) {
+  if (type.typeArguments.isEmpty) {
+    switch (type.baseName) {
+      case 'List':
+        return 'List<Any?>';
+      case 'Map':
+        return 'Map<Any, Any?>';
+      default:
+        return 'Any';
+    }
+  } else {
+    switch (type.baseName) {
+      case 'List':
+        return 'List<${_nullsafeKotlinTypeForDartType(type.typeArguments.first)}>';
+      case 'Map':
+        return 'Map<${_nullsafeKotlinTypeForDartType(type.typeArguments.first)}, ${_nullsafeKotlinTypeForDartType(type.typeArguments.last)}>';
+      default:
+        return '${type.baseName}<${_flattenTypeArguments(type.typeArguments)}>';
+    }
+  }
+}
+
+String? _kotlinTypeForBuiltinDartType(TypeDeclaration type) {
+  const Map<String, String> kotlinTypeForDartTypeMap = <String, String>{
+    'void': 'Void',
+    'bool': 'Boolean',
+    'String': 'String',
+    'int': 'Long',
+    'double': 'Double',
+    'Uint8List': 'ByteArray',
+    'Int32List': 'IntArray',
+    'Int64List': 'LongArray',
+    'Float32List': 'FloatArray',
+    'Float64List': 'DoubleArray',
+    'Object': 'Any',
+  };
+  if (kotlinTypeForDartTypeMap.containsKey(type.baseName)) {
+    return kotlinTypeForDartTypeMap[type.baseName];
+  } else if (type.baseName == 'List' || type.baseName == 'Map') {
+    return _kotlinTypeForBuiltinGenericDartType(type);
+  } else {
+    return null;
+  }
+}
+
+String _kotlinTypeForDartType(TypeDeclaration type) {
+  return _kotlinTypeForBuiltinDartType(type) ?? type.baseName;
+}
+
+String _nullsafeKotlinTypeForDartType(TypeDeclaration type) {
+  final String nullSafe = type.isNullable ? '?' : '';
+  return '${_kotlinTypeForDartType(type)}$nullSafe';
+}
+
+/// Generates the ".kotlin" file for the AST represented by [root] to [sink] with the
+/// provided [options].
+void generateKotlin(KotlinOptions options, Root root, StringSink sink) {
+  final Set<String> rootClassNameSet =
+      root.classes.map((Class x) => x.name).toSet();
+  final Set<String> rootEnumNameSet =
+      root.enums.map((Enum x) => x.name).toSet();
+  final Indent indent = Indent(sink);
+
+  HostDatatype getHostDatatype(NamedType field) {
+    return getFieldHostDatatype(field, root.classes, root.enums,
+        (TypeDeclaration x) => _kotlinTypeForBuiltinDartType(x));
+  }
+
+  void writeHeader() {
+    if (options.copyrightHeader != null) {
+      addLines(indent, options.copyrightHeader!, linePrefix: '// ');
+    }
+    indent.writeln('// $generatedCodeWarning');
+    indent.writeln('// $seeAlsoWarning');
+  }
+
+  void writeImports() {
+    indent.writeln('import android.util.Log');
+    indent.writeln('import io.flutter.plugin.common.BasicMessageChannel');
+    indent.writeln('import io.flutter.plugin.common.BinaryMessenger');
+    indent.writeln('import io.flutter.plugin.common.MessageCodec');
+    indent.writeln('import io.flutter.plugin.common.StandardMessageCodec');
+    indent.writeln('import java.io.ByteArrayOutputStream');
+    indent.writeln('import java.nio.ByteBuffer');
+  }
+
+  void writeEnum(Enum anEnum) {
+    indent.write('enum class ${anEnum.name}(val raw: Int) ');
+    indent.scoped('{', '}', () {
+      // We use explicit indexing here as use of the ordinal() method is
+      // discouraged. The toMap and fromMap API matches class API to allow
+      // the same code to work with enums and classes, but this
+      // can also be done directly in the host and flutter APIs.
+      int index = 0;
+      for (final String member in anEnum.members) {
+        indent.write('${member.toUpperCase()}($index)');
+        if (index != anEnum.members.length - 1) {
+          indent.addln(',');
+        } else {
+          indent.addln(';');
+        }
+        index++;
+      }
+
+      indent.writeln('');
+      indent.write('companion object ');
+      indent.scoped('{', '}', () {
+        indent.write('fun ofRaw(raw: Int): ${anEnum.name}? ');
+        indent.scoped('{', '}', () {
+          indent.writeln('return values().firstOrNull { it.raw == raw }');
+        });
+      });
+    });
+  }
+
+  void writeDataClass(Class klass) {
+    void writeField(NamedType field) {
+      indent.write(
+          'val ${field.name}: ${_nullsafeKotlinTypeForDartType(field.type)}');
+      final String defaultNil = field.type.isNullable ? ' = null' : '';
+      indent.add(defaultNil);
+    }
+
+    void writeToMap() {
+      indent.write('fun toMap(): Map<String, Any?> ');
+      indent.scoped('{', '}', () {
+        indent.writeln('val map = mutableMapOf<String, Any?>()');
+
+        for (final NamedType field in klass.fields) {
+          final HostDatatype hostDatatype = getHostDatatype(field);
+          String toWriteValue = '';
+          final String fieldName = field.name;
+          final String prefix = field.type.isNullable ? 'it' : fieldName;
+          if (!hostDatatype.isBuiltin &&
+              rootClassNameSet.contains(field.type.baseName)) {
+            toWriteValue = '$prefix.toMap()';
+          } else if (!hostDatatype.isBuiltin &&
+              rootEnumNameSet.contains(field.type.baseName)) {
+            toWriteValue = '$prefix.raw';
+          } else {
+            toWriteValue = prefix;
+          }
+
+          if (field.type.isNullable) {
+            indent.writeln(
+                '$fieldName?.let { map["${field.name}"] = $toWriteValue }');
+          } else {
+            indent.writeln('map["${field.name}"] = $toWriteValue');
+          }
+        }
+
+        indent.writeln('return map');
+      });
+    }
+
+    void writeFromMap() {
+      final String className = klass.name;
+
+      indent.write('companion object ');
+      indent.scoped('{', '}', () {
+        indent.writeln('@Suppress("UNCHECKED_CAST")');
+        indent.write('fun fromMap(map: Map<String, Any?>): $className ');
+
+        indent.scoped('{', '}', () {
+          for (final NamedType field in klass.fields) {
+            final HostDatatype hostDatatype = getHostDatatype(field);
+
+            // The StandardMessageCodec can give us [Integer, Long] for
+            // a Dart 'int'.  To keep things simple we just use 64bit
+            // longs in Pigeon with Kotlin.
+            final bool isInt = field.type.baseName == 'int';
+
+            final String mapValue = 'map["${field.name}"]';
+            final String fieldType = _kotlinTypeForDartType(field.type);
+
+            if (field.type.isNullable) {
+              if (!hostDatatype.isBuiltin &&
+                  rootClassNameSet.contains(field.type.baseName)) {
+                indent.write('val ${field.name}: $fieldType? = ');
+                indent.add('($mapValue as? Map<String, Any?>)?.let ');
+                indent.scoped('{', '}', () {
+                  indent.writeln('$fieldType.fromMap(it)');
+                });
+              } else if (!hostDatatype.isBuiltin &&
+                  rootEnumNameSet.contains(field.type.baseName)) {
+                indent.write('val ${field.name}: $fieldType? = ');
+                indent.add('($mapValue as? Int)?.let ');
+                indent.scoped('{', '}', () {
+                  indent.writeln('$fieldType.ofRaw(it)');
+                });
+              } else if (isInt) {
+                indent.write('val ${field.name} = $mapValue');
+                indent.addln(
+                    '.let { if (it is Int) it.toLong() else it as? Long }');
+              } else {
+                indent.writeln('val ${field.name} = $mapValue as? $fieldType');
+              }
+            } else {
+              if (!hostDatatype.isBuiltin &&
+                  rootClassNameSet.contains(field.type.baseName)) {
+                indent.writeln(
+                    'val ${field.name} = $fieldType.fromMap($mapValue as Map<String, Any?>)');
+              } else if (!hostDatatype.isBuiltin &&
+                  rootEnumNameSet.contains(field.type.baseName)) {
+                indent.write(
+                    'val ${field.name} = $fieldType.ofRaw($mapValue as Int)!!');
+              } else {
+                indent.writeln('val ${field.name} = $mapValue as $fieldType');
+              }
+            }
+          }
+
+          indent.writeln('');
+          indent.write('return $className(');
+          for (final NamedType field in klass.fields) {
+            final String comma = klass.fields.last == field ? '' : ', ';
+            indent.add('${field.name}$comma');
+          }
+          indent.addln(')');
+        });
+      });
+    }
+
+    indent.writeln(
+        '/** Generated class from Pigeon that represents data sent in messages. */');
+    indent.write('data class ${klass.name}(');
+    indent.scoped('', '', () {
+      for (final NamedType element in klass.fields) {
+        writeField(element);
+        if (klass.fields.last != element) {
+          indent.addln(',');
+        }
+      }
+    });
+
+    indent.scoped(') {', '}', () {
+      writeFromMap();
+      writeToMap();
+    });
+  }
+
+  void writeApi(Api api) {
+    if (api.location == ApiLocation.host) {
+      _writeHostApi(indent, api, root);
+    } else if (api.location == ApiLocation.flutter) {
+      _writeFlutterApi(indent, api);
+    }
+  }
+
+  void writeWrapResult() {
+    indent.write('private fun wrapResult(result: Any?): Map<String, Any?> ');
+    indent.scoped('{', '}', () {
+      indent.writeln('return hashMapOf("result" to result)');
+    });
+  }
+
+  void writeWrapError() {
+    indent.write(
+        'private fun wrapError(exception: Throwable): Map<String, Any> ');
+    indent.scoped('{', '}', () {
+      indent.write('return ');
+      indent.scoped('hashMapOf<String, Any>(', ')', () {
+        indent.write('"error" to ');
+        indent.scoped('hashMapOf<String, Any>(', ')', () {
+          indent.writeln(
+              '"${Keys.errorCode}" to exception.javaClass.simpleName,');
+          indent.writeln('"${Keys.errorMessage}" to exception.toString(),');
+          indent.writeln(
+              '"${Keys.errorDetails}" to "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)');
+        });
+      });
+    });
+  }
+
+  writeHeader();
+  indent.addln('');
+  if (options.package != null) {
+    indent.writeln('package ${options.package}');
+  }
+  indent.addln('');
+  writeImports();
+  indent.addln('');
+  indent.writeln('/** Generated class from Pigeon. */');
+  for (final Enum anEnum in root.enums) {
+    indent.writeln('');
+    writeEnum(anEnum);
+  }
+
+  for (final Class klass in root.classes) {
+    indent.addln('');
+    writeDataClass(klass);
+  }
+
+  if (root.apis.any((Api api) =>
+      api.location == ApiLocation.host &&
+      api.methods.any((Method it) => it.isAsynchronous))) {
+    indent.addln('');
+  }
+
+  for (final Api api in root.apis) {
+    _writeCodec(indent, api, root);
+    indent.addln('');
+    writeApi(api);
+  }
+
+  indent.addln('');
+  writeWrapResult();
+  indent.addln('');
+  writeWrapError();
+}
diff --git a/packages/pigeon/lib/pigeon.dart b/packages/pigeon/lib/pigeon.dart
index d4875c3..7f2fb75 100644
--- a/packages/pigeon/lib/pigeon.dart
+++ b/packages/pigeon/lib/pigeon.dart
@@ -7,6 +7,7 @@
 export 'cpp_generator.dart' show CppOptions;
 export 'dart_generator.dart' show DartOptions;
 export 'java_generator.dart' show JavaOptions;
+export 'kotlin_generator.dart' show KotlinOptions;
 export 'objc_generator.dart' show ObjcOptions;
 export 'pigeon_lib.dart';
 export 'swift_generator.dart' show SwiftOptions;
diff --git a/packages/pigeon/lib/pigeon_lib.dart b/packages/pigeon/lib/pigeon_lib.dart
index 32c6122..2c04ed3 100644
--- a/packages/pigeon/lib/pigeon_lib.dart
+++ b/packages/pigeon/lib/pigeon_lib.dart
@@ -30,6 +30,7 @@
 import 'generator_tools.dart';
 import 'generator_tools.dart' as generator_tools;
 import 'java_generator.dart';
+import 'kotlin_generator.dart';
 import 'objc_generator.dart';
 import 'swift_generator.dart';
 
@@ -164,6 +165,8 @@
       this.javaOptions,
       this.swiftOut,
       this.swiftOptions,
+      this.kotlinOut,
+      this.kotlinOptions,
       this.cppHeaderOut,
       this.cppSourceOut,
       this.cppOptions,
@@ -203,6 +206,12 @@
   /// Options that control how Swift will be generated.
   final SwiftOptions? swiftOptions;
 
+  /// Path to the kotlin file that will be generated.
+  final String? kotlinOut;
+
+  /// Options that control how Kotlin will be generated.
+  final KotlinOptions? kotlinOptions;
+
   /// Path to the ".h" C++ file that will be generated.
   final String? cppHeaderOut;
 
@@ -247,6 +256,11 @@
       swiftOptions: map.containsKey('swiftOptions')
           ? SwiftOptions.fromMap((map['swiftOptions'] as Map<String, Object>?)!)
           : null,
+      kotlinOut: map['kotlinOut'] as String?,
+      kotlinOptions: map.containsKey('kotlinOptions')
+          ? KotlinOptions.fromMap(
+              (map['kotlinOptions'] as Map<String, Object>?)!)
+          : null,
       cppHeaderOut: map['experimental_cppHeaderOut'] as String?,
       cppSourceOut: map['experimental_cppSourceOut'] as String?,
       cppOptions: map.containsKey('experimental_cppOptions')
@@ -277,6 +291,8 @@
       if (javaOptions != null) 'javaOptions': javaOptions!.toMap(),
       if (swiftOut != null) 'swiftOut': swiftOut!,
       if (swiftOptions != null) 'swiftOptions': swiftOptions!.toMap(),
+      if (kotlinOut != null) 'kotlinOut': kotlinOut!,
+      if (kotlinOptions != null) 'kotlinOptions': kotlinOptions!.toMap(),
       if (cppHeaderOut != null) 'experimental_cppHeaderOut': cppHeaderOut!,
       if (cppSourceOut != null) 'experimental_cppSourceOut': cppSourceOut!,
       if (cppOptions != null) 'experimental_cppOptions': cppOptions!.toMap(),
@@ -571,6 +587,29 @@
   List<Error> validate(PigeonOptions options, Root root) => <Error>[];
 }
 
+/// A [Generator] that generates Kotlin source code.
+class KotlinGenerator implements Generator {
+  /// Constructor for [KotlinGenerator].
+  const KotlinGenerator();
+
+  @override
+  void generate(StringSink sink, PigeonOptions options, Root root) {
+    KotlinOptions kotlinOptions =
+        options.kotlinOptions ?? const KotlinOptions();
+    kotlinOptions = kotlinOptions.merge(KotlinOptions(
+        copyrightHeader: options.copyrightHeader != null
+            ? _lineReader(options.copyrightHeader!)
+            : null));
+    generateKotlin(kotlinOptions, root, sink);
+  }
+
+  @override
+  IOSink? shouldGenerate(PigeonOptions options) => _openSink(options.kotlinOut);
+
+  @override
+  List<Error> validate(PigeonOptions options, Root root) => <Error>[];
+}
+
 dart_ast.Annotation? _findMetadata(
     dart_ast.NodeList<dart_ast.Annotation> metadata, String query) {
   final Iterable<dart_ast.Annotation> annotations = metadata
@@ -1202,6 +1241,11 @@
         help: 'Adds the java.annotation.Generated annotation to the output.')
     ..addOption('experimental_swift_out',
         help: 'Path to generated Swift file (.swift).')
+    ..addOption('experimental_kotlin_out',
+        help: 'Path to generated Kotlin file (.kt). (experimental)')
+    ..addOption('experimental_kotlin_package',
+        help:
+            'The package that generated Kotlin code will be in. (experimental)')
     ..addOption('experimental_cpp_header_out',
         help: 'Path to generated C++ header file (.h). (experimental)')
     ..addOption('experimental_cpp_source_out',
@@ -1247,6 +1291,10 @@
         useGeneratedAnnotation: results['java_use_generated_annotation'],
       ),
       swiftOut: results['experimental_swift_out'],
+      kotlinOut: results['experimental_kotlin_out'],
+      kotlinOptions: KotlinOptions(
+        package: results['experimental_kotlin_package'],
+      ),
       cppHeaderOut: results['experimental_cpp_header_out'],
       cppSourceOut: results['experimental_cpp_source_out'],
       cppOptions: CppOptions(
@@ -1295,6 +1343,7 @@
           const DartGenerator(),
           const JavaGenerator(),
           const SwiftGenerator(),
+          const KotlinGenerator(),
           const CppHeaderGenerator(),
           const CppSourceGenerator(),
           const DartTestGenerator(),
diff --git a/packages/pigeon/pigeons/background_platform_channels.dart b/packages/pigeon/pigeons/background_platform_channels.dart
index c21be6c..643b880 100644
--- a/packages/pigeon/pigeons/background_platform_channels.dart
+++ b/packages/pigeon/pigeons/background_platform_channels.dart
@@ -5,7 +5,7 @@
 import 'package:pigeon/pigeon.dart';
 
 @HostApi()
-abstract class Api2Host {
+abstract class BackgroundApi2Host {
   @TaskQueue(type: TaskQueueType.serialBackgroundThread)
   int add(int x, int y);
 }
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/.gitignore b/packages/pigeon/platform_tests/android_kotlin_unit_tests/.gitignore
new file mode 100644
index 0000000..0fa6b67
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/.gitignore
@@ -0,0 +1,46 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+/build/
+
+# Web related
+lib/generated_plugin_registrant.dart
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/.metadata b/packages/pigeon/platform_tests/android_kotlin_unit_tests/.metadata
new file mode 100644
index 0000000..62b61c3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 891511d58f6550ce9e9b03b8d7c6a602caa97488
+  channel: master
+
+project_type: app
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/README.md b/packages/pigeon/platform_tests/android_kotlin_unit_tests/README.md
new file mode 100644
index 0000000..d2383b7
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/README.md
@@ -0,0 +1,3 @@
+# android_kotlin_unit_tests
+
+Unit-tests for Pigeon generated Kotlin code.  See [../../tools/run_tests.dart](../../tools/run_tests.dart).
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.gitignore b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.gitignore
new file mode 100644
index 0000000..0a741cb
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.gitignore
@@ -0,0 +1,11 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
+key.properties
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.project b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.project
new file mode 100644
index 0000000..fe67538
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/.project
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>android_</name>
+	<comment>Project android_ created by Buildship.</comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
+	</natures>
+	<filteredResources>
+		<filter>
+			<id>1624921942866</id>
+			<name></name>
+			<type>30</type>
+			<matcher>
+				<id>org.eclipse.core.resources.regexFilterMatcher</id>
+				<arguments>node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
+			</matcher>
+		</filter>
+	</filteredResources>
+</projectDescription>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/build.gradle b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/build.gradle
new file mode 100644
index 0000000..38eaf17
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/build.gradle
@@ -0,0 +1,70 @@
+def localProperties = new Properties()
+def localPropertiesFile = rootProject.file('local.properties')
+if (localPropertiesFile.exists()) {
+    localPropertiesFile.withReader('UTF-8') { reader ->
+        localProperties.load(reader)
+    }
+}
+
+def flutterRoot = localProperties.getProperty('flutter.sdk')
+if (flutterRoot == null) {
+    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
+}
+
+def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
+if (flutterVersionCode == null) {
+    flutterVersionCode = '1'
+}
+
+def flutterVersionName = localProperties.getProperty('flutter.versionName')
+if (flutterVersionName == null) {
+    flutterVersionName = '1.0'
+}
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
+
+android {
+    compileSdkVersion 31
+
+    sourceSets {
+        main.java.srcDirs += 'src/main/kotlin'
+    }
+
+    defaultConfig {
+        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+        applicationId "com.example.android_kotlin_unit_tests"
+        minSdkVersion 16
+        targetSdkVersion 30
+        versionCode flutterVersionCode.toInteger()
+        versionName flutterVersionName
+    }
+
+    buildTypes {
+        release {
+            // TODO: Add your own signing config for the release build.
+            // Signing with the debug keys for now, so `flutter run --release` works.
+            signingConfig signingConfigs.debug
+        }
+    }
+
+    testOptions {
+        unitTests.returnDefaultValues = true
+    }
+}
+
+flutter {
+    source '../..'
+}
+
+dependencies {
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+    testImplementation 'junit:junit:4.+'
+    testImplementation "io.mockk:mockk:1.12.4"
+}
+
+
+//test {
+//    useJUnitPlatform()
+//}
\ No newline at end of file
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/debug/AndroidManifest.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..fac2664
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.example.android_kotlin_unit_tests">
+    <!-- Flutter needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/AndroidManifest.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..27ed867
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.example.android_kotlin_unit_tests">
+   <application
+        android:label="android_kotlin_unit_tests"
+        android:icon="@mipmap/ic_launcher">
+        <activity
+            android:name=".MainActivity"
+            android:launchMode="singleTop"
+            android:theme="@style/LaunchTheme"
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+            android:hardwareAccelerated="true"
+            android:windowSoftInputMode="adjustResize">
+            <!-- Specifies an Android theme to apply to this Activity as soon as
+                 the Android process has started. This theme is visible to the user
+                 while the Flutter UI initializes. After that, this theme continues
+                 to determine the Window background behind the Flutter UI. -->
+            <meta-data
+              android:name="io.flutter.embedding.android.NormalTheme"
+              android:resource="@style/NormalTheme"
+              />
+            <!-- Displays an Android View that continues showing the launch screen
+                 Drawable until Flutter paints its first frame, then this splash
+                 screen fades out. A splash screen is useful to avoid any visual
+                 gap between the end of Android's launch screen and the painting of
+                 Flutter's first frame. -->
+            <meta-data
+              android:name="io.flutter.embedding.android.SplashScreenDrawable"
+              android:resource="@drawable/launch_background"
+              />
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity>
+        <!-- Don't delete the meta-data below.
+             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+        <meta-data
+            android:name="flutterEmbedding"
+            android:value="2" />
+    </application>
+</manifest>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/kotlin/com/example/android_kotlin_unit_tests/.gitignore b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/kotlin/com/example/android_kotlin_unit_tests/.gitignore
new file mode 100644
index 0000000..78d4294
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/kotlin/com/example/android_kotlin_unit_tests/.gitignore
@@ -0,0 +1 @@
+*.kt
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="?android:colorBackground" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable/launch_background.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@android:color/white" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a60e5e3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..a60e5e3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..a60e5e3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..a60e5e3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..a60e5e3
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values-night/styles.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..449a9f9
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             Flutter draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+         
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values/styles.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..d74aa35
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             Flutter draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+         
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/profile/AndroidManifest.xml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..fac2664
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.example.android_kotlin_unit_tests">
+    <!-- Flutter needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AllDatatypesTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AllDatatypesTest.kt
new file mode 100644
index 0000000..87a94ca
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AllDatatypesTest.kt
@@ -0,0 +1,112 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+
+internal class AllDatatypesTest: TestCase() {
+    @Test
+    fun testNullValues() {
+        val everything = Everything()
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = FlutterEverything(binaryMessenger)
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = FlutterEverything.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val args = codec.decodeMessage(message) as ArrayList<*>
+            val replyData = codec.encodeMessage(args[0])
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.echo(everything) {
+            didCall = true
+            assertNull(it.aBool)
+            assertNull(it.anInt)
+            assertNull(it.aDouble)
+            assertNull(it.aString)
+            assertNull(it.aByteArray)
+            assertNull(it.a4ByteArray)
+            assertNull(it.a8ByteArray)
+            assertNull(it.aFloatArray)
+            assertNull(it.aList)
+            assertNull(it.aMap)
+            assertNull(it.mapWithObject)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testHasValues() {
+        val everything = Everything(
+            aBool = false,
+            anInt = 1234L,
+            aDouble = 2.0,
+            aString = "hello",
+            aByteArray = byteArrayOf(1, 2, 3, 4),
+            a4ByteArray = intArrayOf(1, 2, 3, 4),
+            a8ByteArray = longArrayOf(1, 2, 3, 4),
+            aFloatArray = doubleArrayOf(0.5, 0.25, 1.5, 1.25),
+            aList = listOf(1, 2, 3),
+            aMap = mapOf("hello" to 1234),
+            mapWithObject = mapOf("hello" to 1234)
+        )
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = FlutterEverything(binaryMessenger)
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = FlutterEverything.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val args = codec.decodeMessage(message) as ArrayList<*>
+            val replyData = codec.encodeMessage(args[0])
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.echo(everything) {
+            didCall = true
+            assertEquals(everything.aBool, it.aBool)
+            assertEquals(everything.anInt, it.anInt)
+            assertEquals(everything.aDouble, it.aDouble)
+            assertEquals(everything.aString, it.aString)
+            assertTrue(everything.aByteArray.contentEquals(it.aByteArray))
+            assertTrue(everything.a4ByteArray.contentEquals(it.a4ByteArray))
+            assertTrue(everything.a8ByteArray.contentEquals(it.a8ByteArray))
+            assertTrue(everything.aFloatArray.contentEquals(it.aFloatArray))
+            assertEquals(everything.aList, it.aList)
+            assertEquals(everything.aMap, it.aMap)
+            assertEquals(everything.mapWithObject, it.mapWithObject)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testIntegerToLong() {
+        val everything = Everything(anInt = 123L)
+        val map = everything.toMap()
+        assertTrue(map.containsKey("anInt"))
+
+        val map2 = hashMapOf("anInt" to 123)
+        val everything2 = Everything.fromMap(map2)
+
+        assertEquals(everything.anInt, everything2.anInt)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AsyncHandlersTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AsyncHandlersTest.kt
new file mode 100644
index 0000000..04937c0
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/AsyncHandlersTest.kt
@@ -0,0 +1,114 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+
+
+internal class AsyncHandlersTest: TestCase() {
+    @Test
+    fun testAsyncHost2Flutter() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = Api2Flutter(binaryMessenger)
+
+        val input = Value(1)
+        val output = Value(2)
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = Api2Flutter.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val replyData = codec.encodeMessage(output)
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.calculate(input) {
+            didCall = true
+            assertEquals(it, output)
+        }
+
+        assertTrue(didCall)
+
+        verify { binaryMessenger.send("dev.flutter.pigeon.Api2Flutter.calculate", any(), any()) }
+    }
+
+    @Test
+    fun testAsyncFlutter2HostCalculate() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = mockk<Api2Host>()
+
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        val input = Value(1)
+        val output = Value(2)
+        val channelName = "dev.flutter.pigeon.Api2Host.calculate"
+
+        every { binaryMessenger.setMessageHandler("dev.flutter.pigeon.Api2Host.voidVoid", any()) } returns Unit
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.calculate(any(), any()) } answers {
+            val callback = arg<(Value) -> Unit>(1)
+            callback(output)
+        }
+
+        Api2Host.setUp(binaryMessenger, api)
+
+        val codec = Api2Host.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(output, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.calculate(input, any()) }
+    }
+
+    @Test
+    fun asyncFlutter2HostVoidVoid() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = mockk<Api2Host>()
+
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        val channelName = "dev.flutter.pigeon.Api2Host.voidVoid"
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { binaryMessenger.setMessageHandler("dev.flutter.pigeon.Api2Host.calculate", any()) } returns Unit
+        every { api.voidVoid(any()) } answers {
+            val callback = arg<() -> Unit>(0)
+            callback()
+        }
+
+        Api2Host.setUp(binaryMessenger, api)
+
+        val codec = Api2Host.codec
+        val message = codec.encodeMessage(null)
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNull(wrapped)
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.voidVoid(any()) }
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EchoBinaryMessenger.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EchoBinaryMessenger.kt
new file mode 100644
index 0000000..652cb13
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EchoBinaryMessenger.kt
@@ -0,0 +1,38 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+
+internal class EchoBinaryMessenger(private val codec: MessageCodec<Any?>): BinaryMessenger {
+    override fun send(channel: String, message: ByteBuffer?) {
+        // Method not implemented because this messenger is just for echoing.
+    }
+
+    override fun send(
+        channel: String,
+        message: ByteBuffer?,
+        callback: BinaryMessenger.BinaryReply?
+    ) {
+        message?.rewind()
+        val args = codec.decodeMessage(message) as ArrayList<*>
+        val replyData = codec.encodeMessage(args[0])
+        replyData?.position(0)
+        callback?.reply(replyData)
+    }
+
+    override fun setMessageHandler(
+        channel: String,
+        handler: BinaryMessenger.BinaryMessageHandler?
+    ) {
+        // Method not implemented because this messenger is just for echoing.
+    }
+
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EnumTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EnumTest.kt
new file mode 100644
index 0000000..1246e2f
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/EnumTest.kt
@@ -0,0 +1,77 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+internal class EnumTest: TestCase() {
+    @Test
+    fun testEchoHost() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = mockk<EnumApi2Host>()
+
+        val channelName = "dev.flutter.pigeon.EnumApi2Host.echo"
+        val input = DataWithEnum(EnumState.SUCCESS)
+
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.echo(any()) } returnsArgument 0
+
+        EnumApi2Host.setUp(binaryMessenger, api)
+
+        val codec = EnumApi2Host.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertTrue(wrapped.containsKey("result"))
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.echo(input) }
+    }
+
+    @Test
+    fun testEchoFlutter() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = EnumApi2Flutter(binaryMessenger)
+
+        val input = DataWithEnum(EnumState.SUCCESS)
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = EnumApi2Flutter.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val args = codec.decodeMessage(message) as ArrayList<*>
+            val replyData = codec.encodeMessage(args[0])
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.echo(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/ListTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/ListTest.kt
new file mode 100644
index 0000000..c8d1669
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/ListTest.kt
@@ -0,0 +1,43 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+class ListTest: TestCase() {
+    @Test
+    fun testListInList() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = EchoApi(binaryMessenger)
+
+        val inside = TestMessage(listOf(1, 2, 3))
+        val input = TestMessage(listOf(inside))
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = EchoApi.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val args = codec.decodeMessage(message) as ArrayList<*>
+            val replyData = codec.encodeMessage(args[0])
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.echo(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/MultipleArityTests.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/MultipleArityTests.kt
new file mode 100644
index 0000000..0144ce1
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/MultipleArityTests.kt
@@ -0,0 +1,76 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+class MultipleArityTests: TestCase() {
+    @Test
+    fun testSimpleHost() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = mockk<MultipleArityHostApi>()
+
+        val inputX = 10L
+        val inputY = 5L
+
+        val channelName = "dev.flutter.pigeon.MultipleArityHostApi.subtract"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.subtract(any(), any()) } answers { firstArg<Long>() - secondArg<Long>() }
+
+        MultipleArityHostApi.setUp(binaryMessenger, api)
+
+        val codec = MultipleArityHostApi.codec
+        val message = codec.encodeMessage(listOf(inputX, inputY))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(inputX - inputY, wrapped["result"])
+            }
+        }
+    }
+
+    @Test
+    fun testSimpleFlutter() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = MultipleArityFlutterApi(binaryMessenger)
+
+        val inputX = 10L
+        val inputY = 5L
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = MultipleArityFlutterApi.codec
+            val message = arg<ByteBuffer>(1)
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            message.position(0)
+            val args = codec.decodeMessage(message) as ArrayList<*>
+            val argX = args[0] as Long
+            val argY = args[1] as Long
+            val replyData = codec.encodeMessage(argX - argY)
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.subtract(inputX, inputY) {
+            didCall = true
+            assertEquals(inputX - inputY, it)
+        }
+
+        assertTrue(didCall)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NonNullFieldsTests.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NonNullFieldsTests.kt
new file mode 100644
index 0000000..7f89152
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NonNullFieldsTests.kt
@@ -0,0 +1,16 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import junit.framework.TestCase
+import org.junit.Test
+
+class NonNullFieldsTests: TestCase() {
+    @Test
+    fun testMake() {
+        val request = NonNullFieldSearchRequest("hello")
+        assertEquals("hello", request.query)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NullableReturnsTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NullableReturnsTest.kt
new file mode 100644
index 0000000..2b8fd2e
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/NullableReturnsTest.kt
@@ -0,0 +1,71 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import junit.framework.TestCase
+import org.junit.Test
+
+class NullableReturnsTest: TestCase() {
+    @Test
+    fun testNullableParameterHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<NullableReturnHostApi>(relaxed = true)
+
+        val output = 1L
+
+        val channelName = "dev.flutter.pigeon.NullableReturnHostApi.doit"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.doit() } returns output
+
+        NullableReturnHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(null)
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(output, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.doit() }
+    }
+
+    @Test
+    fun testNullableParameterFlutter() {
+        val binaryMessenger = mockk<BinaryMessenger>()
+        val api = NullableReturnFlutterApi(binaryMessenger)
+
+        val output = 12L
+
+        every { binaryMessenger.send(any(), any(), any()) } answers {
+            val codec = NullableReturnFlutterApi.codec
+            val reply = arg<BinaryMessenger.BinaryReply>(2)
+            val replyData = codec.encodeMessage(output)
+            replyData?.position(0)
+            reply.reply(replyData)
+        }
+
+        var didCall = false
+        api.doit {
+            didCall = true
+            assertEquals(output, it)
+        }
+
+        assertTrue(didCall)
+    }
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/PrimitiveTest.kt b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/PrimitiveTest.kt
new file mode 100644
index 0000000..e4a86b4
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/app/src/test/java/com/example/android_kotlin_unit_tests/PrimitiveTest.kt
@@ -0,0 +1,434 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package com.example.android_kotlin_unit_tests
+
+import io.flutter.plugin.common.BinaryMessenger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import junit.framework.TestCase
+import org.junit.Test
+import java.nio.ByteBuffer
+import java.util.ArrayList
+
+class PrimitiveTest: TestCase() {
+    @Test
+    fun testIntPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = 1
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.anInt"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.anInt(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input.toLong(), wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.anInt(input.toLong()) }
+    }
+
+    @Test
+    fun testIntPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = 1L
+
+        var didCall = false
+        api.anInt(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testBoolPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = true
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aBool"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aBool(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aBool(input) }
+    }
+
+    @Test
+    fun testBoolPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = true
+
+        var didCall = false
+        api.aBool(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testStringPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = "Hello"
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aString"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aString(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aString(input) }
+    }
+
+    @Test
+    fun testDoublePrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = 1.0
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aDouble"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aDouble(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aDouble(input) }
+    }
+
+    @Test
+    fun testDoublePrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = 1.0
+
+        var didCall = false
+        api.aDouble(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testMapPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = mapOf<Any, Any?>("a" to 1, "b" to 2)
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aMap"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aMap(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aMap(input) }
+    }
+
+    @Test
+    fun testMapPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = mapOf<Any, Any?>("a" to 1, "b" to 2)
+
+        var didCall = false
+        api.aMap(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testListPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = listOf(1, 2, 3)
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aList"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aList(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aList(input) }
+    }
+
+    @Test
+    fun testListPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = listOf(1, 2, 3)
+
+        var didCall = false
+        api.aList(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testInt32ListPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = intArrayOf(1, 2, 3)
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.anInt32List"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.anInt32List(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertTrue(input.contentEquals(wrapped["result"] as IntArray))
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.anInt32List(input) }
+    }
+
+    @Test
+    fun testInt32ListPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = intArrayOf(1, 2, 3)
+
+        var didCall = false
+        api.anInt32List(input) {
+            didCall = true
+            assertTrue(input.contentEquals(it))
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testBoolListPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = listOf(true, false, true)
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aBoolList"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aBoolList(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aBoolList(input) }
+    }
+
+    @Test
+    fun testBoolListPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = listOf(true, false, true)
+
+        var didCall = false
+        api.aBoolList(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+    @Test
+    fun testStringIntMapPrimitiveHost() {
+        val binaryMessenger = mockk<BinaryMessenger>(relaxed = true)
+        val api = mockk<PrimitiveHostApi>(relaxed = true)
+
+        val input = mapOf<String?, Long?>("a" to 1, "b" to 2)
+
+        val channelName = "dev.flutter.pigeon.PrimitiveHostApi.aStringIntMap"
+        val handlerSlot = slot<BinaryMessenger.BinaryMessageHandler>()
+
+        every { binaryMessenger.setMessageHandler(channelName, capture(handlerSlot)) } returns Unit
+        every { api.aStringIntMap(any()) } returnsArgument 0
+
+        PrimitiveHostApi.setUp(binaryMessenger, api)
+
+        val codec = PrimitiveHostApi.codec
+        val message = codec.encodeMessage(listOf(input))
+        message?.rewind()
+        handlerSlot.captured.onMessage(message) {
+            it?.rewind()
+            @Suppress("UNCHECKED_CAST")
+            val wrapped = codec.decodeMessage(it) as HashMap<String, Any>?
+            assertNotNull(wrapped)
+            wrapped?.let {
+                assertEquals(input, wrapped["result"])
+            }
+        }
+
+        verify { binaryMessenger.setMessageHandler(channelName, handlerSlot.captured) }
+        verify { api.aStringIntMap(input) }
+    }
+
+    @Test
+    fun testStringIntMapPrimitiveFlutter() {
+        val binaryMessenger = EchoBinaryMessenger(MultipleArityFlutterApi.codec)
+        val api = PrimitiveFlutterApi(binaryMessenger)
+
+        val input = mapOf<String?, Long?>("a" to 1, "b" to 2)
+
+        var didCall = false
+        api.aStringIntMap(input) {
+            didCall = true
+            assertEquals(input, it)
+        }
+
+        assertTrue(didCall)
+    }
+
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/build.gradle b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/build.gradle
new file mode 100644
index 0000000..410e2c7
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/build.gradle
@@ -0,0 +1,34 @@
+buildscript {
+    ext.kotlin_version = '1.6.0'
+    repositories {
+        google()
+        mavenCentral()
+    }
+
+    dependencies {
+        classpath 'com.android.tools.build:gradle:4.1.0'
+        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+    gradle.projectsEvaluated {
+        tasks.withType(JavaCompile) {
+            options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
+        }
+    }
+}
+
+rootProject.buildDir = '../build'
+subprojects {
+    project.buildDir = "${rootProject.buildDir}/${project.name}"
+    project.evaluationDependsOn(':app')
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle.properties b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle.properties
new file mode 100644
index 0000000..94adc3a
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx1536M
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle/wrapper/gradle-wrapper.properties b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..bc6a58a
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jun 23 08:50:38 CEST 2017
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/settings.gradle b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/settings.gradle
new file mode 100644
index 0000000..44e62bc
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/android/settings.gradle
@@ -0,0 +1,11 @@
+include ':app'
+
+def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
+def properties = new Properties()
+
+assert localPropertiesFile.exists()
+localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
+
+def flutterSdkPath = properties.getProperty("flutter.sdk")
+assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/lib/main.dart b/packages/pigeon/platform_tests/android_kotlin_unit_tests/lib/main.dart
new file mode 100644
index 0000000..f9b0dd7
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/lib/main.dart
@@ -0,0 +1,5 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+void main() {}
diff --git a/packages/pigeon/platform_tests/android_kotlin_unit_tests/pubspec.yaml b/packages/pigeon/platform_tests/android_kotlin_unit_tests/pubspec.yaml
new file mode 100644
index 0000000..1b00c7b
--- /dev/null
+++ b/packages/pigeon/platform_tests/android_kotlin_unit_tests/pubspec.yaml
@@ -0,0 +1,20 @@
+name: android_kotlin_unit_tests
+description: Unit tests for Pigeon generated Java code.
+
+publish_to: "none" # Remove this line if you wish to publish to pub.dev
+version: 1.0.0+1
+
+environment:
+  sdk: ">=2.12.0 <3.0.0"
+
+dependencies:
+  cupertino_icons: ^1.0.2
+  flutter:
+    sdk: flutter
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+
+flutter:
+  uses-material-design: true
diff --git a/packages/pigeon/pubspec.yaml b/packages/pigeon/pubspec.yaml
index 3dcef2b..bc7d9d5 100644
--- a/packages/pigeon/pubspec.yaml
+++ b/packages/pigeon/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Code generator tool to make communication between Flutter and the host platform type-safe and easier.
 repository: https://github.com/flutter/packages/tree/main/packages/pigeon
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Apigeon
-version: 4.1.1 # This must match the version in lib/generator_tools.dart
+version: 4.2.0 # This must match the version in lib/generator_tools.dart
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
diff --git a/packages/pigeon/run_tests.sh b/packages/pigeon/run_tests.sh
index 1df123b..8428943 100755
--- a/packages/pigeon/run_tests.sh
+++ b/packages/pigeon/run_tests.sh
@@ -195,6 +195,10 @@
   dart run tool/run_tests.dart -t mac_swift_unittests
 }
 
+run_android_kotlin_unittests() {
+  dart run tool/run_tests.dart -t android_kotlin_unittests
+}
+
 run_dart_compilation_tests() {
   local temp_dir=$(mktmpdir)
   local flutter_project_dir=$temp_dir/project
@@ -316,6 +320,7 @@
 should_run_ios_unittests=true
 should_run_mock_handler_tests=true
 should_run_macos_swift_unittests=true
+should_run_android_kotlin_unittests=true
 while getopts "t:l?h" opt; do
   case $opt in
   t)
@@ -327,6 +332,7 @@
     should_run_ios_unittests=false
     should_run_mock_handler_tests=false
     should_run_macos_swift_unittests=false
+    should_run_android_kotlin_unittests=false
     case $OPTARG in
     android_unittests) should_run_android_unittests=true ;;
     dart_compilation_tests) should_run_dart_compilation_tests=true ;;
@@ -336,6 +342,7 @@
     ios_unittests) should_run_ios_unittests=true ;;
     mock_handler_tests) should_run_mock_handler_tests=true ;;
     macos_swift_unittests) should_run_macos_swift_unittests=true ;;
+    android_kotlin_unittests) should_run_android_kotlin_unittests=true ;;
     *)
       echo "unrecognized test: $OPTARG"
       exit 1
@@ -344,14 +351,15 @@
     ;;
   l)
     echo "available tests for -t:
-  android_unittests      - Unit tests on generated Java code.
-  dart_compilation_tests - Compilation tests on generated Dart code.
-  dart_unittests         - Unit tests on and analysis on Pigeon's implementation.
-  flutter_unittests      - Unit tests on generated Dart code.
-  ios_e2e_tests          - End-to-end objc tests run on iOS Simulator
-  ios_unittests          - Unit tests on generated Objc code.
-  mock_handler_tests     - Unit tests on generated Dart mock handler code.
-  macos_swift_unittests  - Unit tests on generated Swift code on macOS.
+  android_unittests        - Unit tests on generated Java code.
+  android_kotlin_unittests - Unit tests on generated Kotlin code on Android.
+  dart_compilation_tests   - Compilation tests on generated Dart code.
+  dart_unittests           - Unit tests on and analysis on Pigeon's implementation.
+  flutter_unittests        - Unit tests on generated Dart code.
+  ios_e2e_tests            - End-to-end objc tests run on iOS Simulator
+  ios_unittests            - Unit tests on generated Objc code.
+  mock_handler_tests       - Unit tests on generated Dart mock handler code.
+  macos_swift_unittests    - Unit tests on generated Swift code on macOS.
   "
     exit 1
     ;;
@@ -401,3 +409,6 @@
 if [ "$should_run_macos_swift_unittests" = true ]; then
   run_macos_swift_unittests
 fi
+if [ "$should_run_android_kotlin_unittests" = true ]; then
+  run_android_kotlin_unittests
+fi
diff --git a/packages/pigeon/test/kotlin_generator_test.dart b/packages/pigeon/test/kotlin_generator_test.dart
new file mode 100644
index 0000000..d9d5501
--- /dev/null
+++ b/packages/pigeon/test/kotlin_generator_test.dart
@@ -0,0 +1,996 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:pigeon/ast.dart';
+import 'package:pigeon/kotlin_generator.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('gen one class', () {
+    final Class klass = Class(
+      name: 'Foobar',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'int',
+            isNullable: true,
+          ),
+          name: 'field1',
+        ),
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[klass],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    generateKotlin(kotlinOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Foobar('));
+    expect(code, contains('val field1: Long? = null'));
+    expect(code, contains('fun fromMap(map: Map<String, Any?>): Foobar'));
+    expect(code, contains('fun toMap(): Map<String, Any?>'));
+  });
+
+  test('gen one enum', () {
+    final Enum anEnum = Enum(
+      name: 'Foobar',
+      members: <String>[
+        'one',
+        'two',
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[],
+      enums: <Enum>[anEnum],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    generateKotlin(kotlinOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('enum class Foobar(val raw: Int) {'));
+    expect(code, contains('ONE(0)'));
+    expect(code, contains('TWO(1)'));
+  });
+
+  test('primitive enum host', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Bar', location: ApiLocation.host, methods: <Method>[
+        Method(
+            name: 'bar',
+            returnType: const TypeDeclaration.voidDeclaration(),
+            arguments: <NamedType>[
+              NamedType(
+                  name: 'foo',
+                  type:
+                      const TypeDeclaration(baseName: 'Foo', isNullable: false))
+            ])
+      ])
+    ], classes: <Class>[], enums: <Enum>[
+      Enum(name: 'Foo', members: <String>['one', 'two'])
+    ]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    generateKotlin(kotlinOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('enum class Foo(val raw: Int) {'));
+    expect(code, contains('val fooArg = Foo.ofRaw(args[0] as Int)'));
+  });
+
+  test('gen one host api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: 'input',
+            )
+          ],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ])
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    generateKotlin(kotlinOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('interface Api'));
+    expect(code, contains('fun doSomething(input: Input): Output'));
+    expect(code, contains('channel.setMessageHandler'));
+  });
+
+  test('all the simple datatypes header', () {
+    final Root root = Root(apis: <Api>[], classes: <Class>[
+      Class(name: 'Foobar', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'bool',
+            isNullable: true,
+          ),
+          name: 'aBool',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'int',
+            isNullable: true,
+          ),
+          name: 'aInt',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'double',
+            isNullable: true,
+          ),
+          name: 'aDouble',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'aString',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Uint8List',
+            isNullable: true,
+          ),
+          name: 'aUint8List',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Int32List',
+            isNullable: true,
+          ),
+          name: 'aInt32List',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Int64List',
+            isNullable: true,
+          ),
+          name: 'aInt64List',
+        ),
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Float64List',
+            isNullable: true,
+          ),
+          name: 'aFloat64List',
+        ),
+      ]),
+    ], enums: <Enum>[]);
+
+    final StringBuffer sink = StringBuffer();
+
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    generateKotlin(kotlinOptions, root, sink);
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('val aBool: Boolean? = null'));
+    expect(code, contains('val aInt: Long? = null'));
+    expect(code, contains('val aDouble: Double? = null'));
+    expect(code, contains('val aString: String? = null'));
+    expect(code, contains('val aUint8List: ByteArray? = null'));
+    expect(code, contains('val aInt32List: IntArray? = null'));
+    expect(code, contains('val aInt64List: LongArray? = null'));
+    expect(code, contains('val aFloat64List: DoubleArray? = null'));
+  });
+
+  test('gen one flutter api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: '',
+            )
+          ],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ])
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code,
+        contains('class Api(private val binaryMessenger: BinaryMessenger)'));
+    expect(code, matches('fun doSomething.*Input.*Output'));
+  });
+
+  test('gen host void api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: '',
+            )
+          ],
+          returnType: const TypeDeclaration.voidDeclaration(),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, isNot(matches('.*doSomething(.*) ->')));
+    expect(code, matches('doSomething(.*)'));
+  });
+
+  test('gen flutter void return api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: '',
+            )
+          ],
+          returnType: const TypeDeclaration.voidDeclaration(),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('callback: () -> Unit'));
+    expect(code, contains('callback()'));
+  });
+
+  test('gen host void argument api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doSomething(): Output'));
+    expect(code, contains('wrapped["result"] = api.doSomething()'));
+    expect(code, contains('wrapped["error"] = wrapError(exception)'));
+    expect(code, contains('reply(wrapped)'));
+  });
+
+  test('gen flutter void argument api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doSomething(callback: (Output) -> Unit)'));
+    expect(code, contains('channel.send(null)'));
+  });
+
+  test('gen list', () {
+    final Root root = Root(apis: <Api>[], classes: <Class>[
+      Class(name: 'Foobar', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'List',
+            isNullable: true,
+          ),
+          name: 'field1',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Foobar'));
+    expect(code, contains('val field1: List<Any?>? = null'));
+  });
+
+  test('gen map', () {
+    final Root root = Root(apis: <Api>[], classes: <Class>[
+      Class(name: 'Foobar', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Map',
+            isNullable: true,
+          ),
+          name: 'field1',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Foobar'));
+    expect(code, contains('val field1: Map<Any, Any?>? = null'));
+  });
+
+  test('gen nested', () {
+    final Class klass = Class(
+      name: 'Outer',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Nested',
+            isNullable: true,
+          ),
+          name: 'nested',
+        )
+      ],
+    );
+    final Class nestedClass = Class(
+      name: 'Nested',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'int',
+            isNullable: true,
+          ),
+          name: 'data',
+        )
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[klass, nestedClass],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Outer'));
+    expect(code, contains('data class Nested'));
+    expect(code, contains('val nested: Nested? = null'));
+    expect(code, contains('fun fromMap(map: Map<String, Any?>): Outer'));
+    expect(
+        code,
+        contains(
+            'val nested: Nested? = (map["nested"] as? Map<String, Any?>)?.let'));
+    expect(code, contains('Nested.fromMap(it)'));
+    expect(code, contains('fun toMap(): Map<String, Any?>'));
+  });
+
+  test('gen one async Host Api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: 'arg',
+            )
+          ],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+          isAsynchronous: true,
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ])
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('interface Api'));
+    expect(code, contains('api.doSomething(argArg) {'));
+    expect(code, contains('reply.reply(wrapResult(it))'));
+  });
+
+  test('gen one async Flutter Api', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: '',
+            )
+          ],
+          returnType:
+              const TypeDeclaration(baseName: 'Output', isNullable: false),
+          isAsynchronous: true,
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'input',
+        )
+      ]),
+      Class(name: 'Output', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: true,
+          ),
+          name: 'output',
+        )
+      ])
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('class Api'));
+    expect(code, matches('fun doSomething.*Input.*callback.*Output.*Unit'));
+  });
+
+  test('gen one enum class', () {
+    final Enum anEnum = Enum(
+      name: 'Enum1',
+      members: <String>[
+        'one',
+        'two',
+      ],
+    );
+    final Class klass = Class(
+      name: 'EnumClass',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'Enum1',
+            isNullable: true,
+          ),
+          name: 'enum1',
+        ),
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[klass],
+      enums: <Enum>[anEnum],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('enum class Enum1(val raw: Int)'));
+    expect(code, contains('ONE(0)'));
+    expect(code, contains('TWO(1)'));
+  });
+
+  Iterable<String> makeIterable(String string) sync* {
+    yield string;
+  }
+
+  test('header', () {
+    final Root root = Root(apis: <Api>[], classes: <Class>[], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    final KotlinOptions swiftOptions = KotlinOptions(
+      copyrightHeader: makeIterable('hello world'),
+    );
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, startsWith('// hello world'));
+  });
+
+  test('generics - list', () {
+    final Class klass = Class(
+      name: 'Foobar',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+              baseName: 'List',
+              isNullable: true,
+              typeArguments: <TypeDeclaration>[
+                TypeDeclaration(baseName: 'int', isNullable: true)
+              ]),
+          name: 'field1',
+        ),
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[klass],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Foobar'));
+    expect(code, contains('val field1: List<Long?>'));
+  });
+
+  test('generics - maps', () {
+    final Class klass = Class(
+      name: 'Foobar',
+      fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+              baseName: 'Map',
+              isNullable: true,
+              typeArguments: <TypeDeclaration>[
+                TypeDeclaration(baseName: 'String', isNullable: true),
+                TypeDeclaration(baseName: 'String', isNullable: true),
+              ]),
+          name: 'field1',
+        ),
+      ],
+    );
+    final Root root = Root(
+      apis: <Api>[],
+      classes: <Class>[klass],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('data class Foobar'));
+    expect(code, contains('val field1: Map<String?, String?>'));
+  });
+
+  test('host generics argument', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration.voidDeclaration(),
+              arguments: <NamedType>[
+                NamedType(
+                  type: const TypeDeclaration(
+                      baseName: 'List',
+                      isNullable: false,
+                      typeArguments: <TypeDeclaration>[
+                        TypeDeclaration(baseName: 'int', isNullable: true)
+                      ]),
+                  name: 'arg',
+                )
+              ])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(arg: List<Long?>'));
+  });
+
+  test('flutter generics argument', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration.voidDeclaration(),
+              arguments: <NamedType>[
+                NamedType(
+                  type: const TypeDeclaration(
+                      baseName: 'List',
+                      isNullable: false,
+                      typeArguments: <TypeDeclaration>[
+                        TypeDeclaration(baseName: 'int', isNullable: true)
+                      ]),
+                  name: 'arg',
+                )
+              ])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(argArg: List<Long?>'));
+  });
+
+  test('host generics return', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration(
+                  baseName: 'List',
+                  isNullable: false,
+                  typeArguments: <TypeDeclaration>[
+                    TypeDeclaration(baseName: 'int', isNullable: true)
+                  ]),
+              arguments: <NamedType>[])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(): List<Long?>'));
+    expect(code, contains('wrapped["result"] = api.doit()'));
+    expect(code, contains('reply.reply(wrapped)'));
+  });
+
+  test('flutter generics return', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration(
+                  baseName: 'List',
+                  isNullable: false,
+                  typeArguments: <TypeDeclaration>[
+                    TypeDeclaration(baseName: 'int', isNullable: true)
+                  ]),
+              arguments: <NamedType>[])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(callback: (List<Long?>) -> Unit'));
+    expect(code, contains('val result = it as List<Long?>'));
+    expect(code, contains('callback(result)'));
+  });
+
+  test('host multiple args', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'add',
+          arguments: <NamedType>[
+            NamedType(
+                name: 'x',
+                type:
+                    const TypeDeclaration(isNullable: false, baseName: 'int')),
+            NamedType(
+                name: 'y',
+                type:
+                    const TypeDeclaration(isNullable: false, baseName: 'int')),
+          ],
+          returnType: const TypeDeclaration(baseName: 'int', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun add(x: Long, y: Long): Long'));
+    expect(code, contains('val args = message as List<Any?>'));
+    expect(
+        code,
+        contains(
+            'val xArg = args[0].let { if (it is Int) it.toLong() else it as Long }'));
+    expect(
+        code,
+        contains(
+            'val yArg = args[1].let { if (it is Int) it.toLong() else it as Long }'));
+    expect(code, contains('wrapped["result"] = api.add(xArg, yArg)'));
+    expect(code, contains('reply.reply(wrapped)'));
+  });
+
+  test('flutter multiple args', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+        Method(
+          name: 'add',
+          arguments: <NamedType>[
+            NamedType(
+                name: 'x',
+                type:
+                    const TypeDeclaration(baseName: 'int', isNullable: false)),
+            NamedType(
+                name: 'y',
+                type:
+                    const TypeDeclaration(baseName: 'int', isNullable: false)),
+          ],
+          returnType: const TypeDeclaration(baseName: 'int', isNullable: false),
+        )
+      ])
+    ], classes: <Class>[], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('val channel = BasicMessageChannel'));
+    expect(code, contains('val result = it as Long'));
+    expect(code, contains('callback(result)'));
+    expect(code,
+        contains('fun add(xArg: Long, yArg: Long, callback: (Long) -> Unit)'));
+    expect(code, contains('channel.send(listOf(xArg, yArg)) {'));
+  });
+
+  test('return nullable host', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration(
+                baseName: 'int',
+                isNullable: true,
+              ),
+              arguments: <NamedType>[])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(): Long?'));
+  });
+
+  test('return nullable host async', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration(
+                baseName: 'int',
+                isNullable: true,
+              ),
+              isAsynchronous: true,
+              arguments: <NamedType>[])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(callback: (Long?) -> Unit'));
+  });
+
+  test('nullable argument host', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration.voidDeclaration(),
+              arguments: <NamedType>[
+                NamedType(
+                    name: 'foo',
+                    type: const TypeDeclaration(
+                      baseName: 'int',
+                      isNullable: true,
+                    )),
+              ])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(
+        code,
+        contains(
+            'val fooArg = args[0].let { if (it is Int) it.toLong() else it as? Long }'));
+  });
+
+  test('nullable argument flutter', () {
+    final Root root = Root(
+      apis: <Api>[
+        Api(name: 'Api', location: ApiLocation.flutter, methods: <Method>[
+          Method(
+              name: 'doit',
+              returnType: const TypeDeclaration.voidDeclaration(),
+              arguments: <NamedType>[
+                NamedType(
+                    name: 'foo',
+                    type: const TypeDeclaration(
+                      baseName: 'int',
+                      isNullable: true,
+                    )),
+              ])
+        ])
+      ],
+      classes: <Class>[],
+      enums: <Enum>[],
+    );
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('fun doit(fooArg: Long?, callback: () -> Unit'));
+  });
+
+  test('nonnull fields', () {
+    final Root root = Root(apis: <Api>[
+      Api(name: 'Api', location: ApiLocation.host, methods: <Method>[
+        Method(
+          name: 'doSomething',
+          arguments: <NamedType>[
+            NamedType(
+              type: const TypeDeclaration(
+                baseName: 'Input',
+                isNullable: false,
+              ),
+              name: '',
+            )
+          ],
+          returnType: const TypeDeclaration.voidDeclaration(),
+        )
+      ])
+    ], classes: <Class>[
+      Class(name: 'Input', fields: <NamedType>[
+        NamedType(
+          type: const TypeDeclaration(
+            baseName: 'String',
+            isNullable: false,
+          ),
+          name: 'input',
+        )
+      ]),
+    ], enums: <Enum>[]);
+    final StringBuffer sink = StringBuffer();
+    const KotlinOptions swiftOptions = KotlinOptions();
+    generateKotlin(swiftOptions, root, sink);
+    final String code = sink.toString();
+    expect(code, contains('val input: String\n'));
+  });
+}
diff --git a/packages/pigeon/test/pigeon_lib_test.dart b/packages/pigeon/test/pigeon_lib_test.dart
index 6b28183..120c6a2 100644
--- a/packages/pigeon/test/pigeon_lib_test.dart
+++ b/packages/pigeon/test/pigeon_lib_test.dart
@@ -98,6 +98,18 @@
     expect(opts.swiftOut, equals('Foo.swift'));
   });
 
+  test('parse args - kotlin_out', () {
+    final PigeonOptions opts =
+        Pigeon.parseArgs(<String>['--experimental_kotlin_out', 'Foo.kt']);
+    expect(opts.kotlinOut, equals('Foo.kt'));
+  });
+
+  test('parse args - kotlin_package', () {
+    final PigeonOptions opts = Pigeon.parseArgs(
+        <String>['--experimental_kotlin_package', 'com.google.foo']);
+    expect(opts.kotlinOptions?.package, equals('com.google.foo'));
+  });
+
   test('parse args - experimental_cpp_header_out', () {
     final PigeonOptions opts =
         Pigeon.parseArgs(<String>['--experimental_cpp_header_out', 'foo.h']);
diff --git a/packages/pigeon/test/pigeon_test.dart b/packages/pigeon/test/pigeon_test.dart
index 7982681..c3e7aa7 100644
--- a/packages/pigeon/test/pigeon_test.dart
+++ b/packages/pigeon/test/pigeon_test.dart
@@ -20,4 +20,9 @@
     const SwiftOptions swiftOptions = SwiftOptions();
     expect(swiftOptions, isNotNull);
   });
+
+  test('Should be able to import KotlinOptions', () async {
+    const KotlinOptions kotlinOptions = KotlinOptions();
+    expect(kotlinOptions, isNotNull);
+  });
 }
diff --git a/packages/pigeon/tool/run_tests.dart b/packages/pigeon/tool/run_tests.dart
index 3eb5833..445744e 100644
--- a/packages/pigeon/tool/run_tests.dart
+++ b/packages/pigeon/tool/run_tests.dart
@@ -19,9 +19,10 @@
         exit,
         stderr,
         stdout;
+import 'dart:math';
+
 import 'package:args/args.dart';
 import 'package:meta/meta.dart';
-import 'package:pigeon/functional.dart';
 
 const String _testFlag = 'test';
 const String _listFlag = 'list';
@@ -40,6 +41,9 @@
   'android_unittests': _TestInfo(
       function: _runAndroidUnitTests,
       description: 'Unit tests on generated Java code.'),
+  'android_kotlin_unittests': _TestInfo(
+      function: _runAndroidKotlinUnitTests,
+      description: 'Unit tests on generated Kotlin code.'),
   'dart_compilation_tests': _TestInfo(
       function: _runDartCompilationTests,
       description: 'Compilation tests on generated Dart code.'),
@@ -95,6 +99,75 @@
   throw UnimplementedError('See run_tests.sh.');
 }
 
+Future<int> _runAndroidKotlinUnitTests() async {
+  const String androidKotlinUnitTestsPath =
+      './platform_tests/android_kotlin_unit_tests';
+  const List<String> tests = <String>[
+    'all_datatypes',
+    'all_void',
+    'android_unittests',
+    'async_handlers',
+    'background_platform_channels',
+    'enum_args',
+    'enum',
+    'host2flutter',
+    'list',
+    'message',
+    'multiple_arity',
+    'non_null_fields',
+    'null_fields',
+    'nullable_returns',
+    'primitive',
+    'void_arg_flutter',
+    'void_arg_host',
+    'voidflutter',
+    'voidhost'
+  ];
+  int generateCode = 0;
+
+  for (final String test in tests) {
+    generateCode = await _runPigeon(
+      input: './pigeons/$test.dart',
+      kotlinOut:
+          '$androidKotlinUnitTestsPath/android/app/src/main/kotlin/com/example/android_kotlin_unit_tests/${snakeToPascalCase(test)}.kt',
+      kotlinPackage: 'com.example.android_kotlin_unit_tests',
+    );
+    if (generateCode != 0) {
+      return generateCode;
+    }
+  }
+
+  final Process gradlewExists = await _streamOutput(Process.start(
+    './gradlew',
+    <String>[],
+    workingDirectory: '$androidKotlinUnitTestsPath/android',
+    runInShell: true,
+  ));
+  final int gradlewExistsCode = await gradlewExists.exitCode;
+  if (gradlewExistsCode != 0) {
+    final Process compile = await _streamOutput(Process.start(
+      'flutter',
+      <String>['build', 'apk', '--debug'],
+      workingDirectory: androidKotlinUnitTestsPath,
+      runInShell: true,
+    ));
+    final int compileCode = await compile.exitCode;
+    if (compileCode != 0) {
+      return compileCode;
+    }
+  }
+
+  final Process run = await _streamOutput(Process.start(
+    './gradlew',
+    <String>[
+      'test',
+    ],
+    workingDirectory: '$androidKotlinUnitTestsPath/android',
+  ));
+
+  return run.exitCode;
+}
+
 Future<int> _runDartCompilationTests() async {
   throw UnimplementedError('See run_tests.sh.');
 }
@@ -315,6 +388,8 @@
 
 Future<int> _runPigeon(
     {required String input,
+    String? kotlinOut,
+    String? kotlinPackage,
     String? iosSwiftOut,
     String? cppHeaderOut,
     String? cppSourceOut,
@@ -331,6 +406,12 @@
     '--copyright_header',
     './copyright_header.txt',
   ];
+  if (kotlinOut != null) {
+    args.addAll(<String>['--experimental_kotlin_out', kotlinOut]);
+  }
+  if (kotlinPackage != null) {
+    args.addAll(<String>['--experimental_kotlin_package', kotlinPackage]);
+  }
   if (iosSwiftOut != null) {
     args.addAll(<String>['--experimental_swift_out', iosSwiftOut]);
   }
@@ -436,10 +517,12 @@
   List<String> testsToRun = <String>[];
   if (argResults.wasParsed(_listFlag)) {
     print('available tests:');
+
+    final int columnWidth =
+        _tests.keys.map((String key) => key.length).reduce(max) + 4;
+
     for (final MapEntry<String, _TestInfo> info in _tests.entries) {
-      final int tabCount = (4 - info.key.length / 8).toInt();
-      final String tabs = repeat('\t', tabCount).join();
-      print('${info.key}$tabs- ${info.value.description}');
+      print('${info.key.padRight(columnWidth)}- ${info.value.description}');
     }
     exit(0);
   } else if (argResults.wasParsed('help')) {