[jnigen] Fix summarizer nested class bug (https://github.com/dart-lang/jnigen/issues/312)

* Fix inner classes
* Fix nested class finding
* Remove -r and -v options
* Add checks for nested classes in config
* Remove problematic steps from GH action
* Add anonymous inner class example
* Use sorted() for deterministic order
* Refactor ClassFinder logic and add Unit test
* Filter anonymous classes in summarizer itself
diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml
index 4e00de4..6bcd562 100644
--- a/.github/workflows/test-package.yml
+++ b/.github/workflows/test-package.yml
@@ -392,13 +392,6 @@
           sudo apt-get install -y ninja-build libgtk-3-dev clang-format
       - run: flutter config --enable-linux-desktop
       - run: dart pub get
-      - name: Generate bindings
-        run: |
-          dart run jnigen -Doutput.c.path=_c/ -Doutput.dart.path=_dart/ --config jnigen.yaml
-      - name: Compare generated bindings
-        run: |
-          diff -r _c src/
-          diff -r _dart lib/src/third_party
       - name: Generate full bindings
         run: dart run jnigen --config jnigen.yaml --override classes="org.apache.pdfbox"
       - name: Analyze generated bindings
diff --git a/pkgs/jni/example/pubspec.lock b/pkgs/jni/example/pubspec.lock
index 3080aea..34dff04 100644
--- a/pkgs/jni/example/pubspec.lock
+++ b/pkgs/jni/example/pubspec.lock
@@ -200,7 +200,7 @@
       path: ".."
       relative: true
     source: path
-    version: "0.5.0"
+    version: "0.6.0-dev.1"
   js:
     dependency: transitive
     description:
diff --git a/pkgs/jni/pubspec.yaml b/pkgs/jni/pubspec.yaml
index 4de344f..0c1068a 100644
--- a/pkgs/jni/pubspec.yaml
+++ b/pkgs/jni/pubspec.yaml
@@ -1,6 +1,6 @@
 name: jni
 description: Library to access JNI from dart and flutter
-version: 0.5.0
+version: 0.6.0-dev.1
 repository: https://github.com/dart-lang/jnigen/tree/main/jni
 
 environment:
diff --git a/pkgs/jnigen/CHANGELOG.md b/pkgs/jnigen/CHANGELOG.md
index 6ed1e84..48b1b21 100644
--- a/pkgs/jnigen/CHANGELOG.md
+++ b/pkgs/jnigen/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.6.0-dev.1
+* **Breaking Change** Specifying a class always pulls in nested classes by default. If a nested class is specified in config, it will be an error.
+* Save all `jnigen` logs to a file in `.dart_tool/jnigen/logs/`. This is useful for debugging.
+
 ## 0.6.0-dev.0
 * **Breaking Change** Removed `suspend_fun_to_async` flag from the config. It's now happening by default since we read the Kotlin's metadata and reliably identify the `suspend fun`s.
 
diff --git a/pkgs/jnigen/bin/jnigen.dart b/pkgs/jnigen/bin/jnigen.dart
index be04b58..8dc69e3 100644
--- a/pkgs/jnigen/bin/jnigen.dart
+++ b/pkgs/jnigen/bin/jnigen.dart
@@ -6,6 +6,7 @@
 import 'package:jnigen/src/logging/logging.dart';
 
 void main(List<String> args) async {
+  enableLoggingToFile();
   Config config;
   try {
     config = Config.parseArgs(args);
diff --git a/pkgs/jnigen/example/in_app_java/lib/android_utils.dart b/pkgs/jnigen/example/in_app_java/lib/android_utils.dart
index 77ffac7..028fa42 100644
--- a/pkgs/jnigen/example/in_app_java/lib/android_utils.dart
+++ b/pkgs/jnigen/example/in_app_java/lib/android_utils.dart
@@ -2382,6 +2382,419 @@
   }
 }
 
+/// from: android.os.Build$Partition
+class Build_Partition extends jni.JObject {
+  @override
+  late final jni.JObjType<Build_Partition> $type = type;
+
+  Build_Partition.fromRef(
+    jni.JObjectPtr ref,
+  ) : super.fromRef(ref);
+
+  /// The type which includes information such as the signature of this class.
+  static const type = $Build_PartitionType();
+
+  /// from: static public final java.lang.String PARTITION_NAME_SYSTEM
+  static const PARTITION_NAME_SYSTEM = r"""system""";
+
+  static final _getName = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(
+                  ffi.Pointer<ffi.Void>)>>("Build_Partition__getName")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public java.lang.String getName()
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  jni.JString getName() {
+    return const jni.JStringType().fromRef(_getName(reference).object);
+  }
+
+  static final _getFingerprint = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(
+                  ffi.Pointer<ffi.Void>)>>("Build_Partition__getFingerprint")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public java.lang.String getFingerprint()
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  jni.JString getFingerprint() {
+    return const jni.JStringType().fromRef(_getFingerprint(reference).object);
+  }
+
+  static final _getBuildTimeMillis = jniLookup<
+              ffi.NativeFunction<
+                  jni.JniResult Function(ffi.Pointer<ffi.Void>)>>(
+          "Build_Partition__getBuildTimeMillis")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public long getBuildTimeMillis()
+  int getBuildTimeMillis() {
+    return _getBuildTimeMillis(reference).long;
+  }
+
+  static final _equals1 = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(ffi.Pointer<ffi.Void>,
+                  ffi.Pointer<ffi.Void>)>>("Build_Partition__equals1")
+      .asFunction<
+          jni.JniResult Function(
+              ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>)>();
+
+  /// from: public boolean equals(java.lang.Object object)
+  bool equals1(
+    jni.JObject object,
+  ) {
+    return _equals1(reference, object.reference).boolean;
+  }
+
+  static final _hashCode1 = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(
+                  ffi.Pointer<ffi.Void>)>>("Build_Partition__hashCode1")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public int hashCode()
+  int hashCode1() {
+    return _hashCode1(reference).integer;
+  }
+}
+
+class $Build_PartitionType extends jni.JObjType<Build_Partition> {
+  const $Build_PartitionType();
+
+  @override
+  String get signature => r"Landroid/os/Build$Partition;";
+
+  @override
+  Build_Partition fromRef(jni.JObjectPtr ref) => Build_Partition.fromRef(ref);
+
+  @override
+  jni.JObjType get superType => const jni.JObjectType();
+
+  @override
+  final superCount = 1;
+
+  @override
+  int get hashCode => ($Build_PartitionType).hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    return other.runtimeType == ($Build_PartitionType) &&
+        other is $Build_PartitionType;
+  }
+}
+
+/// from: android.os.Build$VERSION
+class Build_VERSION extends jni.JObject {
+  @override
+  late final jni.JObjType<Build_VERSION> $type = type;
+
+  Build_VERSION.fromRef(
+    jni.JObjectPtr ref,
+  ) : super.fromRef(ref);
+
+  /// The type which includes information such as the signature of this class.
+  static const type = $Build_VERSIONType();
+  static final _get_BASE_OS =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__BASE_OS")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String BASE_OS
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get BASE_OS =>
+      const jni.JStringType().fromRef(_get_BASE_OS().object);
+
+  static final _get_CODENAME =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__CODENAME")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String CODENAME
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get CODENAME =>
+      const jni.JStringType().fromRef(_get_CODENAME().object);
+
+  static final _get_INCREMENTAL =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__INCREMENTAL")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String INCREMENTAL
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get INCREMENTAL =>
+      const jni.JStringType().fromRef(_get_INCREMENTAL().object);
+
+  static final _get_MEDIA_PERFORMANCE_CLASS =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__MEDIA_PERFORMANCE_CLASS")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final int MEDIA_PERFORMANCE_CLASS
+  static int get MEDIA_PERFORMANCE_CLASS =>
+      _get_MEDIA_PERFORMANCE_CLASS().integer;
+
+  static final _get_PREVIEW_SDK_INT =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__PREVIEW_SDK_INT")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final int PREVIEW_SDK_INT
+  static int get PREVIEW_SDK_INT => _get_PREVIEW_SDK_INT().integer;
+
+  static final _get_RELEASE =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__RELEASE")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String RELEASE
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get RELEASE =>
+      const jni.JStringType().fromRef(_get_RELEASE().object);
+
+  static final _get_RELEASE_OR_CODENAME =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__RELEASE_OR_CODENAME")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String RELEASE_OR_CODENAME
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get RELEASE_OR_CODENAME =>
+      const jni.JStringType().fromRef(_get_RELEASE_OR_CODENAME().object);
+
+  static final _get_RELEASE_OR_PREVIEW_DISPLAY =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__RELEASE_OR_PREVIEW_DISPLAY")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String RELEASE_OR_PREVIEW_DISPLAY
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get RELEASE_OR_PREVIEW_DISPLAY =>
+      const jni.JStringType().fromRef(_get_RELEASE_OR_PREVIEW_DISPLAY().object);
+
+  static final _get_SDK =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__SDK")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String SDK
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get SDK =>
+      const jni.JStringType().fromRef(_get_SDK().object);
+
+  static final _get_SDK_INT =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__SDK_INT")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final int SDK_INT
+  static int get SDK_INT => _get_SDK_INT().integer;
+
+  static final _get_SECURITY_PATCH =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Build_VERSION__SECURITY_PATCH")
+          .asFunction<jni.JniResult Function()>();
+
+  /// from: static public final java.lang.String SECURITY_PATCH
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  static jni.JString get SECURITY_PATCH =>
+      const jni.JStringType().fromRef(_get_SECURITY_PATCH().object);
+
+  static final _ctor = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(
+                  ffi.Pointer<ffi.Void>)>>("Build_VERSION__ctor")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public void <init>(android.os.Build $parent)
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  factory Build_VERSION(
+    Build $parent,
+  ) {
+    return Build_VERSION.fromRef(_ctor($parent.reference).object);
+  }
+}
+
+class $Build_VERSIONType extends jni.JObjType<Build_VERSION> {
+  const $Build_VERSIONType();
+
+  @override
+  String get signature => r"Landroid/os/Build$VERSION;";
+
+  @override
+  Build_VERSION fromRef(jni.JObjectPtr ref) => Build_VERSION.fromRef(ref);
+
+  @override
+  jni.JObjType get superType => const jni.JObjectType();
+
+  @override
+  final superCount = 1;
+
+  @override
+  int get hashCode => ($Build_VERSIONType).hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    return other.runtimeType == ($Build_VERSIONType) &&
+        other is $Build_VERSIONType;
+  }
+}
+
+/// from: android.os.Build$VERSION_CODES
+class Build_VERSION_CODES extends jni.JObject {
+  @override
+  late final jni.JObjType<Build_VERSION_CODES> $type = type;
+
+  Build_VERSION_CODES.fromRef(
+    jni.JObjectPtr ref,
+  ) : super.fromRef(ref);
+
+  /// The type which includes information such as the signature of this class.
+  static const type = $Build_VERSION_CODESType();
+
+  /// from: static public final int BASE
+  static const BASE = 1;
+
+  /// from: static public final int BASE_1_1
+  static const BASE_1_1 = 2;
+
+  /// from: static public final int CUPCAKE
+  static const CUPCAKE = 3;
+
+  /// from: static public final int CUR_DEVELOPMENT
+  static const CUR_DEVELOPMENT = 10000;
+
+  /// from: static public final int DONUT
+  static const DONUT = 4;
+
+  /// from: static public final int ECLAIR
+  static const ECLAIR = 5;
+
+  /// from: static public final int ECLAIR_0_1
+  static const ECLAIR_0_1 = 6;
+
+  /// from: static public final int ECLAIR_MR1
+  static const ECLAIR_MR1 = 7;
+
+  /// from: static public final int FROYO
+  static const FROYO = 8;
+
+  /// from: static public final int GINGERBREAD
+  static const GINGERBREAD = 9;
+
+  /// from: static public final int GINGERBREAD_MR1
+  static const GINGERBREAD_MR1 = 10;
+
+  /// from: static public final int HONEYCOMB
+  static const HONEYCOMB = 11;
+
+  /// from: static public final int HONEYCOMB_MR1
+  static const HONEYCOMB_MR1 = 12;
+
+  /// from: static public final int HONEYCOMB_MR2
+  static const HONEYCOMB_MR2 = 13;
+
+  /// from: static public final int ICE_CREAM_SANDWICH
+  static const ICE_CREAM_SANDWICH = 14;
+
+  /// from: static public final int ICE_CREAM_SANDWICH_MR1
+  static const ICE_CREAM_SANDWICH_MR1 = 15;
+
+  /// from: static public final int JELLY_BEAN
+  static const JELLY_BEAN = 16;
+
+  /// from: static public final int JELLY_BEAN_MR1
+  static const JELLY_BEAN_MR1 = 17;
+
+  /// from: static public final int JELLY_BEAN_MR2
+  static const JELLY_BEAN_MR2 = 18;
+
+  /// from: static public final int KITKAT
+  static const KITKAT = 19;
+
+  /// from: static public final int KITKAT_WATCH
+  static const KITKAT_WATCH = 20;
+
+  /// from: static public final int LOLLIPOP
+  static const LOLLIPOP = 21;
+
+  /// from: static public final int LOLLIPOP_MR1
+  static const LOLLIPOP_MR1 = 22;
+
+  /// from: static public final int M
+  static const M = 23;
+
+  /// from: static public final int N
+  static const N = 24;
+
+  /// from: static public final int N_MR1
+  static const N_MR1 = 25;
+
+  /// from: static public final int O
+  static const O = 26;
+
+  /// from: static public final int O_MR1
+  static const O_MR1 = 27;
+
+  /// from: static public final int P
+  static const P = 28;
+
+  /// from: static public final int Q
+  static const Q = 29;
+
+  /// from: static public final int R
+  static const R = 30;
+
+  /// from: static public final int S
+  static const S = 31;
+
+  /// from: static public final int S_V2
+  static const S_V2 = 32;
+
+  /// from: static public final int TIRAMISU
+  static const TIRAMISU = 33;
+
+  static final _ctor = jniLookup<
+          ffi.NativeFunction<
+              jni.JniResult Function(
+                  ffi.Pointer<ffi.Void>)>>("Build_VERSION_CODES__ctor")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public void <init>(android.os.Build $parent)
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  factory Build_VERSION_CODES(
+    Build $parent,
+  ) {
+    return Build_VERSION_CODES.fromRef(_ctor($parent.reference).object);
+  }
+}
+
+class $Build_VERSION_CODESType extends jni.JObjType<Build_VERSION_CODES> {
+  const $Build_VERSION_CODESType();
+
+  @override
+  String get signature => r"Landroid/os/Build$VERSION_CODES;";
+
+  @override
+  Build_VERSION_CODES fromRef(jni.JObjectPtr ref) =>
+      Build_VERSION_CODES.fromRef(ref);
+
+  @override
+  jni.JObjType get superType => const jni.JObjectType();
+
+  @override
+  final superCount = 1;
+
+  @override
+  int get hashCode => ($Build_VERSION_CODESType).hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    return other.runtimeType == ($Build_VERSION_CODESType) &&
+        other is $Build_VERSION_CODESType;
+  }
+}
+
 /// from: android.os.Build
 class Build extends jni.JObject {
   @override
@@ -2688,8 +3101,8 @@
 
   /// from: static public java.util.List getFingerprintedPartitions()
   /// The returned object must be deleted after use, by calling the `delete` method.
-  static jni.JList<jni.JObject> getFingerprintedPartitions() {
-    return const jni.JListType(jni.JObjectType())
+  static jni.JList<Build_Partition> getFingerprintedPartitions() {
+    return const jni.JListType($Build_PartitionType())
         .fromRef(_getFingerprintedPartitions().object);
   }
 
diff --git a/pkgs/jnigen/example/in_app_java/src/android_utils/android_utils.c b/pkgs/jnigen/example/in_app_java/src/android_utils/android_utils.c
index 6cf0cf2..c41ff6e 100644
--- a/pkgs/jnigen/example/in_app_java/src/android_utils/android_utils.c
+++ b/pkgs/jnigen/example/in_app_java/src/android_utils/android_utils.c
@@ -1352,6 +1352,284 @@
   return to_global_ref_result(_result);
 }
 
+// android.os.Build$Partition
+jclass _c_Build_Partition = NULL;
+
+jmethodID _m_Build_Partition__getName = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_Partition__getName(jobject self_) {
+  load_env();
+  load_class_global_ref(&_c_Build_Partition, "android/os/Build$Partition");
+  if (_c_Build_Partition == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_Partition, &_m_Build_Partition__getName, "getName",
+              "()Ljava/lang/String;");
+  if (_m_Build_Partition__getName == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  jobject _result =
+      (*jniEnv)->CallObjectMethod(jniEnv, self_, _m_Build_Partition__getName);
+  return to_global_ref_result(_result);
+}
+
+jmethodID _m_Build_Partition__getFingerprint = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_Partition__getFingerprint(jobject self_) {
+  load_env();
+  load_class_global_ref(&_c_Build_Partition, "android/os/Build$Partition");
+  if (_c_Build_Partition == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_Partition, &_m_Build_Partition__getFingerprint,
+              "getFingerprint", "()Ljava/lang/String;");
+  if (_m_Build_Partition__getFingerprint == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  jobject _result = (*jniEnv)->CallObjectMethod(
+      jniEnv, self_, _m_Build_Partition__getFingerprint);
+  return to_global_ref_result(_result);
+}
+
+jmethodID _m_Build_Partition__getBuildTimeMillis = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_Partition__getBuildTimeMillis(jobject self_) {
+  load_env();
+  load_class_global_ref(&_c_Build_Partition, "android/os/Build$Partition");
+  if (_c_Build_Partition == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_Partition, &_m_Build_Partition__getBuildTimeMillis,
+              "getBuildTimeMillis", "()J");
+  if (_m_Build_Partition__getBuildTimeMillis == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  int64_t _result = (*jniEnv)->CallLongMethod(
+      jniEnv, self_, _m_Build_Partition__getBuildTimeMillis);
+  return (JniResult){.value = {.j = _result}, .exception = check_exception()};
+}
+
+jmethodID _m_Build_Partition__equals1 = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_Partition__equals1(jobject self_, jobject object) {
+  load_env();
+  load_class_global_ref(&_c_Build_Partition, "android/os/Build$Partition");
+  if (_c_Build_Partition == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_Partition, &_m_Build_Partition__equals1, "equals",
+              "(Ljava/lang/Object;)Z");
+  if (_m_Build_Partition__equals1 == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  uint8_t _result = (*jniEnv)->CallBooleanMethod(
+      jniEnv, self_, _m_Build_Partition__equals1, object);
+  return (JniResult){.value = {.z = _result}, .exception = check_exception()};
+}
+
+jmethodID _m_Build_Partition__hashCode1 = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_Partition__hashCode1(jobject self_) {
+  load_env();
+  load_class_global_ref(&_c_Build_Partition, "android/os/Build$Partition");
+  if (_c_Build_Partition == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_Partition, &_m_Build_Partition__hashCode1, "hashCode",
+              "()I");
+  if (_m_Build_Partition__hashCode1 == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  int32_t _result =
+      (*jniEnv)->CallIntMethod(jniEnv, self_, _m_Build_Partition__hashCode1);
+  return (JniResult){.value = {.i = _result}, .exception = check_exception()};
+}
+
+// android.os.Build$VERSION
+jclass _c_Build_VERSION = NULL;
+
+jmethodID _m_Build_VERSION__ctor = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_VERSION__ctor(jobject _parent) {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_VERSION, &_m_Build_VERSION__ctor, "<init>",
+              "(Landroid/os/Build;)V");
+  if (_m_Build_VERSION__ctor == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  jobject _result = (*jniEnv)->NewObject(jniEnv, _c_Build_VERSION,
+                                         _m_Build_VERSION__ctor, _parent);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__BASE_OS = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__BASE_OS() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__BASE_OS, "BASE_OS",
+                    "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(jniEnv, _c_Build_VERSION,
+                                                    _f_Build_VERSION__BASE_OS);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__CODENAME = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__CODENAME() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__CODENAME, "CODENAME",
+                    "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(jniEnv, _c_Build_VERSION,
+                                                    _f_Build_VERSION__CODENAME);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__INCREMENTAL = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__INCREMENTAL() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__INCREMENTAL,
+                    "INCREMENTAL", "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__INCREMENTAL);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__MEDIA_PERFORMANCE_CLASS = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__MEDIA_PERFORMANCE_CLASS() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION,
+                    &_f_Build_VERSION__MEDIA_PERFORMANCE_CLASS,
+                    "MEDIA_PERFORMANCE_CLASS", "I");
+  int32_t _result = (*jniEnv)->GetStaticIntField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__MEDIA_PERFORMANCE_CLASS);
+  return (JniResult){.value = {.i = _result}, .exception = check_exception()};
+}
+
+jfieldID _f_Build_VERSION__PREVIEW_SDK_INT = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__PREVIEW_SDK_INT() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__PREVIEW_SDK_INT,
+                    "PREVIEW_SDK_INT", "I");
+  int32_t _result = (*jniEnv)->GetStaticIntField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__PREVIEW_SDK_INT);
+  return (JniResult){.value = {.i = _result}, .exception = check_exception()};
+}
+
+jfieldID _f_Build_VERSION__RELEASE = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__RELEASE() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__RELEASE, "RELEASE",
+                    "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(jniEnv, _c_Build_VERSION,
+                                                    _f_Build_VERSION__RELEASE);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__RELEASE_OR_CODENAME = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__RELEASE_OR_CODENAME() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__RELEASE_OR_CODENAME,
+                    "RELEASE_OR_CODENAME", "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__RELEASE_OR_CODENAME);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__RELEASE_OR_PREVIEW_DISPLAY = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__RELEASE_OR_PREVIEW_DISPLAY() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION,
+                    &_f_Build_VERSION__RELEASE_OR_PREVIEW_DISPLAY,
+                    "RELEASE_OR_PREVIEW_DISPLAY", "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__RELEASE_OR_PREVIEW_DISPLAY);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__SDK = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__SDK() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__SDK, "SDK",
+                    "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(jniEnv, _c_Build_VERSION,
+                                                    _f_Build_VERSION__SDK);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Build_VERSION__SDK_INT = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__SDK_INT() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__SDK_INT, "SDK_INT",
+                    "I");
+  int32_t _result = (*jniEnv)->GetStaticIntField(jniEnv, _c_Build_VERSION,
+                                                 _f_Build_VERSION__SDK_INT);
+  return (JniResult){.value = {.i = _result}, .exception = check_exception()};
+}
+
+jfieldID _f_Build_VERSION__SECURITY_PATCH = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Build_VERSION__SECURITY_PATCH() {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION, "android/os/Build$VERSION");
+  if (_c_Build_VERSION == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Build_VERSION, &_f_Build_VERSION__SECURITY_PATCH,
+                    "SECURITY_PATCH", "Ljava/lang/String;");
+  jobject _result = (*jniEnv)->GetStaticObjectField(
+      jniEnv, _c_Build_VERSION, _f_Build_VERSION__SECURITY_PATCH);
+  return to_global_ref_result(_result);
+}
+
+// android.os.Build$VERSION_CODES
+jclass _c_Build_VERSION_CODES = NULL;
+
+jmethodID _m_Build_VERSION_CODES__ctor = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Build_VERSION_CODES__ctor(jobject _parent) {
+  load_env();
+  load_class_global_ref(&_c_Build_VERSION_CODES,
+                        "android/os/Build$VERSION_CODES");
+  if (_c_Build_VERSION_CODES == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Build_VERSION_CODES, &_m_Build_VERSION_CODES__ctor, "<init>",
+              "(Landroid/os/Build;)V");
+  if (_m_Build_VERSION_CODES__ctor == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  jobject _result = (*jniEnv)->NewObject(jniEnv, _c_Build_VERSION_CODES,
+                                         _m_Build_VERSION_CODES__ctor, _parent);
+  return to_global_ref_result(_result);
+}
+
 // android.os.Build
 jclass _c_Build = NULL;
 
diff --git a/pkgs/jnigen/java/.gitignore b/pkgs/jnigen/java/.gitignore
index fe41dfd..5ccba17 100644
--- a/pkgs/jnigen/java/.gitignore
+++ b/pkgs/jnigen/java/.gitignore
@@ -8,3 +8,5 @@
 .idea/jarRepositories.xml
 .idea/misc.xml
 .idea/runConfigurations.xml
+
+.classpath ## Used for experimenting with JShell REPL
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
index 1eda460..0077fd7 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
@@ -7,7 +7,10 @@
 import com.github.dart_lang.jnigen.apisummarizer.disasm.AsmSummarizer;
 import com.github.dart_lang.jnigen.apisummarizer.doclet.SummarizerDoclet;
 import com.github.dart_lang.jnigen.apisummarizer.elements.ClassDecl;
-import com.github.dart_lang.jnigen.apisummarizer.util.*;
+import com.github.dart_lang.jnigen.apisummarizer.util.ClassFinder;
+import com.github.dart_lang.jnigen.apisummarizer.util.InputStreamProvider;
+import com.github.dart_lang.jnigen.apisummarizer.util.JsonWriter;
+import com.github.dart_lang.jnigen.apisummarizer.util.StreamUtil;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
@@ -38,16 +41,12 @@
       Class<? extends Doclet> docletClass,
       List<JavaFileObject> fileObjects,
       SummarizerOptions options) {
-    Log.setVerbose(options.verbose);
     var fileManager = javaDoc.getStandardFileManager(null, null, null);
     var cli = new ArrayList<String>();
     cli.add((options.useModules ? "--module-" : "--") + "source-path=" + options.sourcePath);
     if (options.classPath != null) {
       cli.add("--class-path=" + options.classPath);
     }
-    if (options.addDependencies) {
-      cli.add("--expand-requires=all");
-    }
     cli.addAll(List.of("-encoding", "utf8"));
 
     if (options.toolArgs != null) {
@@ -73,7 +72,6 @@
     } else {
       output = new FileOutputStream(options.outputFile);
     }
-
     List<String> sourcePaths =
         options.sourcePath != null
             ? Arrays.asList(options.sourcePath.split(File.pathSeparator))
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java
index ca63a45..8a3e545 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java
@@ -1,5 +1,6 @@
 package com.github.dart_lang.jnigen.apisummarizer;
 
+import java.util.Arrays;
 import org.apache.commons.cli.*;
 
 public class SummarizerOptions {
@@ -9,7 +10,6 @@
   boolean useModules;
   Main.Backend backend;
   String modulesList;
-  boolean addDependencies;
   String toolArgs;
   boolean verbose;
   String outputFile;
@@ -19,15 +19,13 @@
 
   public static SummarizerOptions fromCommandLine(CommandLine cmd) {
     var opts = new SummarizerOptions();
-    opts.sourcePath = cmd.getOptionValue("sources", ".");
+    opts.sourcePath = cmd.getOptionValue("sources", null);
     var backendString = cmd.getOptionValue("backend", "auto");
     opts.backend = Main.Backend.valueOf(backendString.toUpperCase());
     opts.classPath = cmd.getOptionValue("classes", null);
     opts.useModules = cmd.hasOption("use-modules");
     opts.modulesList = cmd.getOptionValue("module-names", null);
-    opts.addDependencies = cmd.hasOption("recursive");
     opts.toolArgs = cmd.getOptionValue("doctool-args", null);
-    opts.verbose = cmd.hasOption("verbose");
     opts.outputFile = cmd.getOptionValue("output-file", null);
     opts.args = cmd.getArgs();
     if (opts.args.length == 0) {
@@ -47,28 +45,16 @@
             true,
             "backend to use for summary generation ('doclet', 'asm' or 'auto' (default)).");
     Option useModules = new Option("M", "use-modules", false, "use Java modules");
-    Option recursive = new Option("r", "recursive", false, "include dependencies of classes");
     Option moduleNames =
         new Option("m", "module-names", true, "comma separated list of module names");
     Option doctoolArgs =
         new Option("D", "doctool-args", true, "arguments to pass to the documentation tool");
-    Option verbose = new Option("v", "verbose", false, "enable verbose output");
     Option outputFile =
         new Option("o", "output-file", true, "write JSON to file instead of stdout");
-    for (Option opt :
-        new Option[] {
-          sources,
-          classes,
-          backend,
-          useModules,
-          recursive,
-          moduleNames,
-          doctoolArgs,
-          verbose,
-          outputFile,
-        }) {
-      options.addOption(opt);
-    }
+    Option[] allOptions = {
+      sources, classes, backend, useModules, moduleNames, doctoolArgs, outputFile
+    };
+    Arrays.stream(allOptions).forEach(options::addOption);
 
     HelpFormatter help = new HelpFormatter();
 
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmClassVisitor.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmClassVisitor.java
index 50bf2f0..6a536d3 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmClassVisitor.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmClassVisitor.java
@@ -4,9 +4,6 @@
 
 package com.github.dart_lang.jnigen.apisummarizer.disasm;
 
-import static org.objectweb.asm.Opcodes.ACC_PROTECTED;
-import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
-
 import com.github.dart_lang.jnigen.apisummarizer.elements.*;
 import com.github.dart_lang.jnigen.apisummarizer.util.SkipException;
 import com.github.dart_lang.jnigen.apisummarizer.util.StreamUtil;
@@ -59,10 +56,6 @@
     super.visit(version, access, name, signature, superName, interfaces);
   }
 
-  private static boolean isPrivate(int access) {
-    return ((access & ACC_PUBLIC) == 0) && ((access & ACC_PROTECTED) == 0);
-  }
-
   @Override
   public FieldVisitor visitField(
       int access, String name, String descriptor, String signature, Object value) {
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/doclet/SummarizerDoclet.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/doclet/SummarizerDoclet.java
index b1664e5..abbc7cc 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/doclet/SummarizerDoclet.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/doclet/SummarizerDoclet.java
@@ -46,7 +46,7 @@
 
   @Override
   public boolean run(DocletEnvironment docletEnvironment) {
-    Log.timed("Initializing doclet");
+    Log.info("Initializing doclet");
     utils = AstEnv.fromEnvironment(docletEnvironment);
     SummarizingScanner scanner = new SummarizingScanner();
     docletEnvironment.getSpecifiedElements().forEach(e -> scanner.scan(e, new SummaryCollector()));
@@ -77,7 +77,7 @@
 
     @Override
     public Void visitPackage(PackageElement e, SummaryCollector collector) {
-      Log.verbose("Visiting package: %s", e.getQualifiedName());
+      Log.info("Visiting package: %s", e.getQualifiedName());
       collector.packages.push(new Package());
       System.out.println("package: " + e.getQualifiedName());
       var result = super.visitPackage(e, collector);
@@ -91,7 +91,7 @@
       if (!collector.types.isEmpty()) {
         return null;
       }
-      Log.verbose("Visiting class: %s, %s", e.getQualifiedName(), collector.types);
+      Log.info("Visiting class: %s, %s", e.getQualifiedName(), collector.types);
       switch (e.getKind()) {
         case CLASS:
         case INTERFACE:
@@ -102,11 +102,11 @@
             super.visitType(e, collector);
             types.add(collector.types.pop());
           } catch (SkipException skip) {
-            Log.always("Skip type: %s", e.getQualifiedName());
+            Log.info("Skip type: %s", e.getQualifiedName());
           }
           break;
         case ANNOTATION_TYPE:
-          Log.always("Skip annotation type: %s", e.getQualifiedName());
+          Log.info("Skip annotation type: %s", e.getQualifiedName());
           break;
       }
       return null;
@@ -149,7 +149,7 @@
             collector.method = null;
             cls.methods.add(method);
           } catch (SkipException skip) {
-            Log.always("Skip method: %s", element.getSimpleName());
+            Log.info("Skip method: %s", element.getSimpleName());
           }
           break;
         case STATIC_INIT:
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ClassFinder.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ClassFinder.java
index 2938022..23c1ca5 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ClassFinder.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ClassFinder.java
@@ -1,7 +1,11 @@
 package com.github.dart_lang.jnigen.apisummarizer.util;
 
+import static com.github.dart_lang.jnigen.apisummarizer.util.ExceptionUtil.wrapCheckedException;
+
 import java.io.File;
-import java.io.FileFilter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.*;
 import java.util.function.BiFunction;
 import java.util.function.Function;
@@ -13,84 +17,146 @@
 import javax.tools.StandardJavaFileManager;
 
 public class ClassFinder {
+  // If class is A$B$C, simpleName can be B$C or A$B$C. Doesn't matter much, because
+  // A can't be anonymous class.
+  private static boolean isNonAnonymousNestedClassName(String simpleName) {
+    String[] nestedParts = simpleName.split("\\$");
+    return Arrays.stream(nestedParts).allMatch(part -> part.matches("[a-zA-Z_][a-zA-Z0-9_]*"));
+  }
+
+  public static boolean isNestedClassOf(String pathString, String fqnWithSlashes, String suffix) {
+    var fqnWithSlashesDollarSign = fqnWithSlashes + "$";
+    if (!pathString.startsWith(fqnWithSlashesDollarSign) || !pathString.endsWith(suffix)) {
+      return false;
+    }
+    String nested =
+        pathString.substring(
+            fqnWithSlashesDollarSign.length(), pathString.length() - suffix.length());
+    return isNonAnonymousNestedClassName(nested);
+  }
+
+  private static boolean isNonAnonymousClassFullPath(String path) {
+    String[] pathParts = path.split("[/\\\\]");
+    String simpleNameWithExt = pathParts[pathParts.length - 1];
+    int extIndex = simpleNameWithExt.indexOf('.');
+    assert extIndex != -1 : "Should've passed full path with extension to this method";
+    String simpleName = simpleNameWithExt.substring(0, extIndex);
+    return isNonAnonymousNestedClassName(simpleName);
+  }
+
+  // Finds [fqn] and its children with [suffix] in [entries].
+  public static Optional<List<String>> findClassAndChildren(
+      TreeSet<String> entries, String fqn, String sep, String suffix) {
+    String fqnWithSlashes = fqn.replace(".", sep);
+    String fqnWithSlashesSuffix = fqnWithSlashes + suffix;
+    String fqnWithSlashesSlash = fqnWithSlashes + sep;
+    String fqnWithSlashesDollarSign = fqnWithSlashes + "$";
+    if (entries.contains(fqnWithSlashesSuffix)) {
+      List<String> classes = new ArrayList<>();
+      // Add nested classes first, because they're alphabetically first
+      entries.tailSet(fqnWithSlashesDollarSign).stream()
+          .takeWhile(entry -> entry.startsWith(fqnWithSlashesDollarSign))
+          // Note: filter comes after takeWhile, because it can filter out additional elements
+          // eg: Class$1 - but there may be valid nested classes after Class$1.
+          .filter(entry -> isNestedClassOf(entry, fqnWithSlashes, suffix))
+          .forEach(classes::add);
+      classes.add(fqnWithSlashesSuffix);
+      return Optional.of(classes);
+    }
+
+    // consider fqnWithSlashes as a directory
+    List<String> children =
+        entries.tailSet(fqnWithSlashesSlash).stream()
+            // takeWhile instead of filter - the difference is O(log n + k) instead of O(n)
+            // so always use takeWhile when doing a treeSet subset stream.
+            .takeWhile(entry -> entry.startsWith(fqnWithSlashesSlash))
+            .filter(entry -> entry.endsWith(suffix))
+            .filter(ClassFinder::isNonAnonymousClassFullPath)
+            .collect(Collectors.toList());
+    return children.isEmpty() ? Optional.empty() : Optional.of(children);
+  }
+
   public static <E> void findFilesInPath(
-      String searchPath,
+      String searchLocation,
       String suffix,
       Map<String, List<E>> classes,
-      Function<List<File>, List<E>> mapper) {
+      Function<List<Path>, List<E>> mapper) {
+    Path searchPath = Path.of(searchLocation);
 
-    for (var binaryName : classes.keySet()) {
-      if (classes.get(binaryName) != null) {
+    TreeSet<String> filePaths;
+    try (var walk = Files.walk(searchPath)) {
+      filePaths =
+          walk.map(searchPath::relativize)
+              .map(Path::toString)
+              .collect(Collectors.toCollection(TreeSet::new));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    for (var className : classes.keySet()) {
+      if (classes.get(className) != null) { // Already found by other method of searching
         continue;
       }
-      var s = binaryName.replace(".", File.separator);
-      var f = new File(searchPath, s + suffix);
-      if (f.exists() && f.isFile()) {
-        classes.put(binaryName, mapper.apply(List.of(f)));
-      }
-
-      var d = new File(searchPath, s);
-      if (d.exists() && d.isDirectory()) {
-        var files = recursiveListFiles(d, file -> file.getName().endsWith(suffix));
-        classes.put(binaryName, mapper.apply(files));
+      var resultPaths = findClassAndChildren(filePaths, className, File.separator, suffix);
+      if (resultPaths.isPresent()) {
+        // [filePaths] and [resultPaths] are relativized to searchPath.
+        // perform opposite operation (resolve) to get full paths.
+        var fullPaths =
+            resultPaths.get().stream().map(searchPath::resolve).collect(Collectors.toList());
+        classes.put(className, mapper.apply(fullPaths));
       }
     }
   }
 
-  public static <E> void findFilesInJar(
+  public static <E> boolean findFilesInJar(
       Map<String, List<E>> classes,
       JarFile jar,
       String suffix,
       BiFunction<JarFile, List<ZipEntry>, List<E>> mapper) {
-    var entries =
+
+    // It appears JAR file entries are always separated by "/"
+    var jarSeparator = "/";
+    var entryNames =
         jar.stream().map(JarEntry::getName).collect(Collectors.toCollection(TreeSet::new));
-    for (var binaryName : classes.keySet()) {
-      if (classes.get(binaryName) != null) {
+    boolean foundClassesInThisJar = false;
+    for (var fqn : classes.keySet()) {
+      if (classes.get(fqn) != null) { // already found
         continue;
       }
-      var relativePath = binaryName.replace('.', '/');
-
-      var filePath = relativePath + suffix;
-      if (entries.contains(filePath)) {
-        var found = List.of(jar.getEntry(filePath));
-        classes.put(binaryName, mapper.apply(jar, found));
-      }
-
-      // Obtain set of all strings prefixed with relativePath + '/'
-      var dirPath = relativePath + '/';
-      var children =
-          entries.tailSet(dirPath).stream()
-              .takeWhile(e -> e.startsWith(dirPath))
-              .filter(e -> e.endsWith(suffix))
-              .map(jar::getEntry)
-              .collect(Collectors.toList());
-      if (!children.isEmpty()) {
-        var mapped = mapper.apply(jar, children);
-        classes.put(binaryName, mapped);
+      var resultPaths = findClassAndChildren(entryNames, fqn, jarSeparator, suffix);
+      if (resultPaths.isPresent()) {
+        var jarEntries = resultPaths.get().stream().map(jar::getEntry).collect(Collectors.toList());
+        classes.put(fqn, mapper.apply(jar, jarEntries));
+        foundClassesInThisJar = true;
       }
     }
+    return foundClassesInThisJar;
   }
 
   public static <T> void find(
       Map<String, List<T>> classes,
       List<String> searchPaths,
       String suffix,
-      Function<List<File>, List<T>> fileMapper,
+      Function<List<Path>, List<T>> fileMapper,
       BiFunction<JarFile, List<ZipEntry>, List<T>> entryMapper) {
     for (var searchPath : searchPaths) {
       File searchFile = new File(searchPath);
       if (searchFile.isDirectory()) {
         findFilesInPath(searchPath, suffix, classes, fileMapper);
       } else if (searchFile.isFile() && searchPath.endsWith(".jar")) {
-        var jarFile = ExceptionUtil.wrapCheckedException(JarFile::new, searchPath);
-        findFilesInJar(classes, jarFile, suffix, entryMapper);
+        var jarFile = wrapCheckedException(JarFile::new, searchPath);
+        var useful = findFilesInJar(classes, jarFile, suffix, entryMapper);
+        if (!useful) {
+          wrapCheckedException(jarFile::close);
+        }
       }
     }
   }
 
   private static List<JavaFileObject> getJavaFileObjectsFromFiles(
-      List<File> files, StandardJavaFileManager fm) {
+      List<Path> paths, StandardJavaFileManager fm) {
     var result = new ArrayList<JavaFileObject>();
+    var files = StreamUtil.map(paths, Path::toFile);
     fm.getJavaFileObjectsFromFiles(files).forEach(result::add);
     return result;
   }
@@ -100,8 +166,8 @@
     return StreamUtil.map(entries, (entry) -> new JarEntryFileObject(jarFile, entry));
   }
 
-  private static List<InputStreamProvider> getInputStreamProvidersFromFiles(List<File> files) {
-    return StreamUtil.map(files, FileInputStreamProvider::new);
+  private static List<InputStreamProvider> getInputStreamProvidersFromFiles(List<Path> files) {
+    return StreamUtil.map(files, (path) -> new FileInputStreamProvider(path.toFile()));
   }
 
   private static List<InputStreamProvider> getInputStreamProvidersFromJar(
@@ -130,37 +196,4 @@
         ClassFinder::getInputStreamProvidersFromFiles,
         ClassFinder::getInputStreamProvidersFromJar);
   }
-
-  /**
-   * Lists all files under given directory, which satisfy the condition of filter. <br>
-   * The order of listing will be deterministic.
-   */
-  public static List<File> recursiveListFiles(File file, FileFilter filter) {
-    if (!file.exists()) {
-      throw new RuntimeException("File not found: " + file.getPath());
-    }
-
-    if (!file.isDirectory()) {
-      return List.of(file);
-    }
-
-    // List files using a breadth-first traversal.
-    var files = new ArrayList<File>();
-    var queue = new ArrayDeque<File>();
-    queue.add(file);
-    while (!queue.isEmpty()) {
-      var dir = queue.poll();
-      var list = dir.listFiles(entry -> entry.isDirectory() || filter.accept(entry));
-      if (list == null) throw new IllegalArgumentException("File.listFiles returned null!");
-      Arrays.sort(list);
-      for (var path : list) {
-        if (path.isDirectory()) {
-          queue.add(path);
-        } else {
-          files.add(path);
-        }
-      }
-    }
-    return files;
-  }
 }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ExceptionUtil.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ExceptionUtil.java
index 3ed92a5..6b4c8bd 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ExceptionUtil.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/ExceptionUtil.java
@@ -2,19 +2,32 @@
 
 public class ExceptionUtil {
   @FunctionalInterface
-  public interface FunctionThrowingException<T, R> {
+  public interface CheckedFunction<T, R> {
     R function(T value) throws Exception;
   }
 
+  @FunctionalInterface
+  public interface CheckedRunnable {
+    void run() throws Exception;
+  }
+
   /**
    * Wraps a function throwing a checked exception and throws all checked exceptions as runtime
    * exceptions.
    */
-  public static <T, R> R wrapCheckedException(FunctionThrowingException<T, R> function, T value) {
+  public static <T, R> R wrapCheckedException(CheckedFunction<T, R> function, T value) {
     try {
       return function.function(value);
     } catch (Exception e) {
       throw new RuntimeException(e);
     }
   }
+
+  public static void wrapCheckedException(CheckedRunnable runnable) {
+    try {
+      runnable.run();
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
 }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JsonWriter.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JsonWriter.java
index 873a6be..112e241 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JsonWriter.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JsonWriter.java
@@ -11,7 +11,7 @@
 public class JsonWriter {
   public static void writeJSON(List<ClassDecl> classes, OutputStream output) {
     var mapper = new ObjectMapper();
-    Log.timed("Writing JSON");
+    Log.info("Writing JSON for %d classes", classes.size());
     mapper.enable(SerializationFeature.INDENT_OUTPUT);
     mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
     try {
@@ -19,6 +19,6 @@
     } catch (IOException e) {
       e.printStackTrace();
     }
-    Log.timed("Finished");
+    Log.info("Finished");
   }
 }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/Log.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/Log.java
index b2b2d78..dfb48e1 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/Log.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/Log.java
@@ -4,30 +4,22 @@
 
 package com.github.dart_lang.jnigen.apisummarizer.util;
 
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
 public class Log {
-  private static long lastPrinted = System.currentTimeMillis();
+  private static final Logger logger = Logger.getLogger("ApiSummarizer");
 
-  public static void setVerbose(boolean verbose) {
-    Log.verboseLogs = verbose;
+  private static void log(Level level, String format, Object... args) {
+    String formatted = String.format(format, args);
+    logger.log(level, formatted);
   }
 
-  private static boolean verboseLogs = false;
-
-  public static void verbose(String format, Object... args) {
-    if (!verboseLogs) {
-      return;
-    }
-    System.err.printf(format + "\n", args);
+  public static void info(String format, Object... args) {
+    log(Level.INFO, format, args);
   }
 
-  public static void timed(String format, Object... args) {
-    long now = System.currentTimeMillis();
-    System.err.printf("[%6d ms] ", now - lastPrinted);
-    lastPrinted = now;
-    System.err.printf(format + "\n", args);
-  }
-
-  public static void always(String format, Object... args) {
-    System.err.printf(format + "\n", args);
+  public static void warning(String format, Object... args) {
+    log(Level.WARNING, format, args);
   }
 }
diff --git a/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/ClassFinderTest.java b/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/ClassFinderTest.java
new file mode 100644
index 0000000..4209e3a
--- /dev/null
+++ b/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/ClassFinderTest.java
@@ -0,0 +1,95 @@
+package com.github.dart_lang.jnigen.apisummarizer;
+
+import static com.github.dart_lang.jnigen.apisummarizer.util.ClassFinder.findClassAndChildren;
+import static com.github.dart_lang.jnigen.apisummarizer.util.ClassFinder.isNestedClassOf;
+import static org.junit.Assert.*;
+
+import java.util.*;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+public class ClassFinderTest {
+  @Test
+  public void testNestedClassCheck() {
+    assertTrue(
+        "Single nested class",
+        isNestedClassOf("com/abc/Class$Nested.class", "com/abc/Class", ".class"));
+    assertTrue(
+        "Nested twice",
+        isNestedClassOf("com/abc/Class$Nested$Twice.class", "com/abc/Class", ".class"));
+    assertTrue(
+        "Single nested class - backslash separator",
+        isNestedClassOf("com\\abc\\Class$Nested.class", "com\\abc\\Class", ".class"));
+    assertFalse(
+        "Anon inner class", isNestedClassOf("com/abc/Class$1.class", "com/abc/Class", ".class"));
+    assertFalse(
+        "Anon inner class inside nested class",
+        isNestedClassOf("com/abc/Class$Nested$1.class", "com/abc/Class", ".class"));
+    assertFalse(
+        "Different class name",
+        isNestedClassOf("com/abc/AClass$Nested.class", "com/abc/Class", ".class"));
+  }
+
+  private Optional<List<String>> pathListOf(String sep, String... paths) {
+    List<String> pathList =
+        Arrays.stream(paths).map(path -> path.replace("/", sep)).collect(Collectors.toList());
+    return Optional.of(pathList);
+  }
+
+  @Test
+  public void testFindChildren() {
+    TreeSet<String> entriesWithSlash =
+        new TreeSet<>(
+            List.of(
+                "random/os/App.class",
+                "random/os/App$1.class",
+                "random/os/App$1$3.class",
+                "random/os/Process.class",
+                "random/os/Process$Fork.class",
+                "random/os/Process$Fork$1.class",
+                "random/os/Process$Fork$A$B$C$2.class",
+                "random/widget/Dialog.class",
+                "random/widget/Dialog$Button.class",
+                "random/widget/Dialog$Button$2.class",
+                "random/widget/Dialog$Button$Color.class",
+                "random/widget/Dialogue$Button.class",
+                "random/time/Clock.class",
+                "random/time/Clock$1.class",
+                "random/time/Calendar.class",
+                "random/time/Calendar$Month$1.class",
+                "random/time/Calendar$Month.class"));
+    TreeSet<String> entriesWithBackslash =
+        entriesWithSlash.stream()
+            .map(x -> x.replace('/', '\\'))
+            .collect(Collectors.toCollection(TreeSet::new));
+    Map<String, TreeSet<String>> bySeparater =
+        Map.of("/", entriesWithSlash, "\\", entriesWithBackslash);
+
+    for (var sep : bySeparater.keySet()) {
+      var entries = bySeparater.get(sep);
+      assertEquals(
+          pathListOf(sep, "random/os/Process$Fork.class", "random/os/Process.class"),
+          findClassAndChildren(entries, "random.os.Process", sep, ".class"));
+      assertEquals(
+          pathListOf(
+              sep,
+              "random/time/Calendar$Month.class",
+              "random/time/Calendar.class",
+              "random/time/Clock.class"),
+          findClassAndChildren(entries, "random.time", sep, ".class"));
+      assertEquals(
+          pathListOf(
+              sep,
+              "random/widget/Dialog$Button$Color.class",
+              "random/widget/Dialog$Button.class",
+              "random/widget/Dialog.class"),
+          findClassAndChildren(entries, "random.widget.Dialog", sep, ".class"));
+      assertEquals(
+          pathListOf(sep, "random/os/App.class"),
+          findClassAndChildren(entries, "random.os.App", sep, ".class"));
+      assertEquals(
+          pathListOf(sep, "random/os/App.class"),
+          findClassAndChildren(entries, "random.os.App", sep, ".class"));
+    }
+  }
+}
diff --git a/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/JSONComparisonTest.java b/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/JSONComparisonTest.java
index 4d15ad2..b02ee15 100644
--- a/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/JSONComparisonTest.java
+++ b/pkgs/jnigen/java/src/test/java/com/github/dart_lang/jnigen/apisummarizer/JSONComparisonTest.java
@@ -1,5 +1,6 @@
 package com.github.dart_lang.jnigen.apisummarizer;
 
+import com.github.dart_lang.jnigen.apisummarizer.util.Log;
 import java.io.File;
 import java.io.IOException;
 import org.junit.Assert;
@@ -31,11 +32,22 @@
   @Test
   public void testExampleSummary() throws IOException, InterruptedException {
     var tempFile = File.createTempFile("summarizer_test", ".json");
+    Log.info("Temporary file: %s", tempFile.getPath());
     Main.main(
         new String[] {
           "-s", "src/test/resources", "com.example.Example", "-o", tempFile.getPath(),
         });
     int comparison = gitDiff(exampleClassJsonOutput, tempFile);
+    if (comparison != 0) {
+      Log.warning("New output (%s) is different than reference output.", tempFile.getPath());
+    }
+
+    // Fail test if git diff exited with 1
     Assert.assertEquals(0, comparison);
+
+    var deleted = tempFile.delete();
+    if (!deleted) {
+      Log.warning("Cannot delete temp file %s", tempFile.getPath());
+    }
   }
 }
diff --git a/pkgs/jnigen/java/src/test/resources/exampleClassSummary.json b/pkgs/jnigen/java/src/test/resources/exampleClassSummary.json
index 90a08e4..208e7ae 100644
--- a/pkgs/jnigen/java/src/test/resources/exampleClassSummary.json
+++ b/pkgs/jnigen/java/src/test/resources/exampleClassSummary.json
@@ -1,9 +1,7 @@
 [ {
   "declKind" : "CLASS",
   "modifiers" : [ "public" ],
-  "simpleName" : "Example",
   "binaryName" : "com.example.Example",
-  "packageName" : "com.example",
   "methods" : [ {
     "modifiers" : [ "public" ],
     "name" : "<init>",
@@ -140,10 +138,8 @@
 }, {
   "declKind" : "CLASS",
   "modifiers" : [ "static", "public" ],
-  "simpleName" : "Aux",
   "binaryName" : "com.example.Example$Aux",
   "parentName" : "com.example.Example",
-  "packageName" : "com.example",
   "methods" : [ {
     "modifiers" : [ "public" ],
     "name" : "<init>",
diff --git a/pkgs/jnigen/lib/src/config/config_types.dart b/pkgs/jnigen/lib/src/config/config_types.dart
index f737b81..c1d166b 100644
--- a/pkgs/jnigen/lib/src/config/config_types.dart
+++ b/pkgs/jnigen/lib/src/config/config_types.dart
@@ -268,6 +268,29 @@
   ClassFilter? classes;
 }
 
+bool _isCapitalized(String s) {
+  final firstLetter = s.substring(0, 1);
+  return firstLetter == firstLetter.toUpperCase();
+}
+
+void _validateClassName(String className) {
+  final parts = className.split('.');
+  assert(parts.isNotEmpty);
+  const nestedClassesInfo =
+      "Nested classes cannot be specified separately. Specifying the "
+      "parent class will pull the nested classes.";
+  if (parts.length > 1 && _isCapitalized(parts[parts.length - 2])) {
+    // Try to detect possible nested classes specified using dot notation eg:
+    // `com.package.Class.NestedClass` and emit a warning.
+    log.warning("It appears a nested class $className is specified in the "
+        "config. $nestedClassesInfo");
+  }
+  if (className.contains('\$')) {
+    throw ConfigException(
+        "Nested class $className not allowed. $nestedClassesInfo");
+  }
+}
+
 /// Configuration for jnigen binding generation.
 class Config {
   Config({
@@ -283,7 +306,11 @@
     this.logLevel = Level.INFO,
     this.dumpJsonTo,
     this.imports,
-  });
+  }) {
+    for (final className in classes) {
+      _validateClassName(className);
+    }
+  }
 
   /// Output configuration for generated bindings
   OutputConfig outputConfig;
diff --git a/pkgs/jnigen/lib/src/logging/logging.dart b/pkgs/jnigen/lib/src/logging/logging.dart
index 67c605e..71db217 100644
--- a/pkgs/jnigen/lib/src/logging/logging.dart
+++ b/pkgs/jnigen/lib/src/logging/logging.dart
@@ -18,10 +18,69 @@
   return message;
 }
 
+/// Format [DateTime] for use in filename
+String _formatTime(DateTime now) {
+  return '${now.year}-${now.month}-${now.day}-'
+      '${now.hour}.${now.minute}.${now.second}';
+}
+
+// We need to respect logging level for console but log everything to file.
+// Hierarchical logging is convoluted. I'm just keeping track of log level.
+var _logLevel = Level.INFO;
+
+final _logDirUri = Directory.current.uri.resolve(".dart_tool/jnigen/logs/");
+
+final _logDir = () {
+  final dir = Directory.fromUri(_logDirUri);
+  dir.createSync(recursive: true);
+  return dir;
+}();
+
+Uri _getDefaultLogFileUri() =>
+    _logDir.uri.resolve("jnigen-${_formatTime(DateTime.now())}.log");
+
+IOSink? _logStream;
+
+/// Enable saving the logs to a file.
+///
+/// This is only meant to be called from an application entry point such as
+/// `main`.
+void enableLoggingToFile() {
+  _deleteOldLogFiles();
+  if (_logStream != null) {
+    throw StateError('Log file is already set');
+  }
+  _logStream = File.fromUri(_getDefaultLogFileUri()).openWrite();
+}
+
+// Maximum number of log files to keep.
+const _maxLogFiles = 5;
+
+/// Delete log files except most recent [_maxLogFiles] files.
+void _deleteOldLogFiles() {
+  final logFiles = _logDir.listSync().map((f) => File(f.path)).toList();
+  // sort in descending order of last modified time.
+  logFiles
+      .sort((f1, f2) => f2.lastModifiedSync().compareTo(f1.lastModifiedSync()));
+  final toDelete = logFiles.length < _maxLogFiles
+      ? const <File>[]
+      : logFiles.sublist(_maxLogFiles - 1);
+  for (final oldLogFile in toDelete) {
+    oldLogFile.deleteSync();
+  }
+}
+
 Logger log = () {
+  // initialize the logger.
   final jnigenLogger = Logger('jnigen');
-  Logger.root.level = Level.INFO;
+  Logger.root.level = Level.ALL;
   Logger.root.onRecord.listen((r) {
+    // Write to file regardless of level.
+    _logStream?.writeln('${r.level} ${r.time}: ${r.message}');
+    // write to console only if level is above configured level.
+    if (r.level < _logLevel) {
+      return;
+    }
     var message = '(${r.loggerName}) ${r.level.name}: ${r.message}';
     if ((r.level == Level.SHOUT || r.level == Level.SEVERE)) {
       message = _colorize(message, _ansiRed);
@@ -35,10 +94,8 @@
 
 /// Set logging level to [level].
 void setLoggingLevel(Level level) {
-  /// This initializes `log` as a side effect, so that level setting we apply
-  /// is always the last one applied.
   log.fine('Set log level: $level');
-  Logger.root.level = level;
+  _logLevel = level;
 }
 
 /// Prints [message] without logging information.
@@ -55,3 +112,15 @@
     return exit(exitCode);
   }
 }
+
+extension WriteToFile on Logger {
+  void writeToFile(Object? data) {
+    _logStream?.writeln(data);
+  }
+
+  void writeSectionToFile(String? sectionName, Object? data) {
+    _logStream?.writeln("==== Begin $sectionName ====");
+    _logStream?.writeln(data);
+    _logStream?.writeln("==== End $sectionName ====");
+  }
+}
diff --git a/pkgs/jnigen/lib/src/summary/summary.dart b/pkgs/jnigen/lib/src/summary/summary.dart
index 31473a6..09c13f5 100644
--- a/pkgs/jnigen/lib/src/summary/summary.dart
+++ b/pkgs/jnigen/lib/src/summary/summary.dart
@@ -187,6 +187,8 @@
       stderrBuffer.toString(),
       'Cannot generate summary: $e',
     );
+  } finally {
+    log.writeSectionToFile("summarizer logs", stderrBuffer.toString());
   }
   if (json == null) {
     throw SummaryParseException('Expected JSON element from summarizer.');
diff --git a/pkgs/jnigen/lib/src/tools/android_sdk_tools.dart b/pkgs/jnigen/lib/src/tools/android_sdk_tools.dart
index 466863c..3de5575 100644
--- a/pkgs/jnigen/lib/src/tools/android_sdk_tools.dart
+++ b/pkgs/jnigen/lib/src/tools/android_sdk_tools.dart
@@ -209,6 +209,9 @@
           'yet cached. Please run `flutter build apk`$inAndroidProject and try '
           'again\n');
     }
+    // Record both stdout and stderr of gradle.
+    log.writeSectionToFile("Gradle logs ($stubName)", procRes.stderr);
+    log.writeSectionToFile("Gradle output ($stubName)", procRes.stdout);
     final output = procRes.stdout as String;
     if (output.isEmpty) {
       printError(procRes.stderr);
diff --git a/pkgs/jnigen/pubspec.yaml b/pkgs/jnigen/pubspec.yaml
index 75be98a..ee70430 100644
--- a/pkgs/jnigen/pubspec.yaml
+++ b/pkgs/jnigen/pubspec.yaml
@@ -3,7 +3,7 @@
 # BSD-style license that can be found in the LICENSE file.
 
 name: jnigen
-version: 0.6.0-dev.0
+version: 0.6.0-dev.1
 description: Experimental generator for FFI+JNI bindings.
 repository: https://github.com/dart-lang/jnigen/tree/main/jnigen
 
diff --git a/pkgs/jnigen/test/config_test.dart b/pkgs/jnigen/test/config_test.dart
index 8538ac1..4b6617b 100644
--- a/pkgs/jnigen/test/config_test.dart
+++ b/pkgs/jnigen/test/config_test.dart
@@ -132,5 +132,9 @@
       name: 'Invalid log level',
       overrides: ['-Dlog_level=inf'],
     );
+    testForErrorChecking<ConfigException>(
+      name: 'Nested class specified',
+      overrides: ['-Dclasses=com.android.Clock\$Clock'],
+    );
   });
 }
diff --git a/pkgs/jnigen/test/simple_package_test/c_based/c_bindings/simple_package.c b/pkgs/jnigen/test/simple_package_test/c_based/c_bindings/simple_package.c
index 4a170e7..6c0f3e2 100644
--- a/pkgs/jnigen/test/simple_package_test/c_based/c_bindings/simple_package.c
+++ b/pkgs/jnigen/test/simple_package_test/c_based/c_bindings/simple_package.c
@@ -607,6 +607,24 @@
   return to_global_ref_result(_result);
 }
 
+jmethodID _m_Example_Nested__usesAnonymousInnerClass = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Example_Nested__usesAnonymousInnerClass(jobject self_) {
+  load_env();
+  load_class_global_ref(
+      &_c_Example_Nested,
+      "com/github/dart_lang/jnigen/simple_package/Example$Nested");
+  if (_c_Example_Nested == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Example_Nested, &_m_Example_Nested__usesAnonymousInnerClass,
+              "usesAnonymousInnerClass", "()V");
+  if (_m_Example_Nested__usesAnonymousInnerClass == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  (*jniEnv)->CallVoidMethod(jniEnv, self_,
+                            _m_Example_Nested__usesAnonymousInnerClass);
+  return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+}
+
 jmethodID _m_Example_Nested__getValue = NULL;
 FFI_PLUGIN_EXPORT
 JniResult Example_Nested__getValue(jobject self_) {
@@ -642,6 +660,59 @@
   return (JniResult){.value = {.j = 0}, .exception = check_exception()};
 }
 
+// com.github.dart_lang.jnigen.simple_package.Example$Nested$NestedTwice
+jclass _c_Example_Nested_NestedTwice = NULL;
+
+jmethodID _m_Example_Nested_NestedTwice__ctor = NULL;
+FFI_PLUGIN_EXPORT
+JniResult Example_Nested_NestedTwice__ctor() {
+  load_env();
+  load_class_global_ref(
+      &_c_Example_Nested_NestedTwice,
+      "com/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice");
+  if (_c_Example_Nested_NestedTwice == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_method(_c_Example_Nested_NestedTwice,
+              &_m_Example_Nested_NestedTwice__ctor, "<init>", "()V");
+  if (_m_Example_Nested_NestedTwice__ctor == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  jobject _result = (*jniEnv)->NewObject(jniEnv, _c_Example_Nested_NestedTwice,
+                                         _m_Example_Nested_NestedTwice__ctor);
+  return to_global_ref_result(_result);
+}
+
+jfieldID _f_Example_Nested_NestedTwice__ZERO = NULL;
+FFI_PLUGIN_EXPORT
+JniResult get_Example_Nested_NestedTwice__ZERO() {
+  load_env();
+  load_class_global_ref(
+      &_c_Example_Nested_NestedTwice,
+      "com/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice");
+  if (_c_Example_Nested_NestedTwice == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Example_Nested_NestedTwice,
+                    &_f_Example_Nested_NestedTwice__ZERO, "ZERO", "I");
+  int32_t _result =
+      (*jniEnv)->GetStaticIntField(jniEnv, _c_Example_Nested_NestedTwice,
+                                   _f_Example_Nested_NestedTwice__ZERO);
+  return (JniResult){.value = {.i = _result}, .exception = check_exception()};
+}
+
+FFI_PLUGIN_EXPORT
+JniResult set_Example_Nested_NestedTwice__ZERO(int32_t value) {
+  load_env();
+  load_class_global_ref(
+      &_c_Example_Nested_NestedTwice,
+      "com/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice");
+  if (_c_Example_Nested_NestedTwice == NULL)
+    return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+  load_static_field(_c_Example_Nested_NestedTwice,
+                    &_f_Example_Nested_NestedTwice__ZERO, "ZERO", "I");
+  (*jniEnv)->SetStaticIntField(jniEnv, _c_Example_Nested_NestedTwice,
+                               _f_Example_Nested_NestedTwice__ZERO, value);
+  return (JniResult){.value = {.j = 0}, .exception = check_exception()};
+}
+
 // com.github.dart_lang.jnigen.simple_package.Exceptions
 jclass _c_Exceptions = NULL;
 
diff --git a/pkgs/jnigen/test/simple_package_test/c_based/dart_bindings/simple_package.dart b/pkgs/jnigen/test/simple_package_test/c_based/dart_bindings/simple_package.dart
index 4e16f1e..d0ad47c 100644
--- a/pkgs/jnigen/test/simple_package_test/c_based/dart_bindings/simple_package.dart
+++ b/pkgs/jnigen/test/simple_package_test/c_based/dart_bindings/simple_package.dart
@@ -559,6 +559,17 @@
     return Example_Nested.fromRef(_ctor(value ? 1 : 0).object);
   }
 
+  static final _usesAnonymousInnerClass = jniLookup<
+              ffi.NativeFunction<
+                  jni.JniResult Function(ffi.Pointer<ffi.Void>)>>(
+          "Example_Nested__usesAnonymousInnerClass")
+      .asFunction<jni.JniResult Function(ffi.Pointer<ffi.Void>)>();
+
+  /// from: public void usesAnonymousInnerClass()
+  void usesAnonymousInnerClass() {
+    return _usesAnonymousInnerClass(reference).check();
+  }
+
   static final _getValue = jniLookup<
           ffi.NativeFunction<
               jni.JniResult Function(
@@ -610,6 +621,72 @@
   }
 }
 
+/// from: com.github.dart_lang.jnigen.simple_package.Example$Nested$NestedTwice
+class Example_Nested_NestedTwice extends jni.JObject {
+  @override
+  late final jni.JObjType<Example_Nested_NestedTwice> $type = type;
+
+  Example_Nested_NestedTwice.fromRef(
+    jni.JObjectPtr ref,
+  ) : super.fromRef(ref);
+
+  /// The type which includes information such as the signature of this class.
+  static const type = $Example_Nested_NestedTwiceType();
+  static final _get_ZERO =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+              "get_Example_Nested_NestedTwice__ZERO")
+          .asFunction<jni.JniResult Function()>();
+
+  static final _set_ZERO =
+      jniLookup<ffi.NativeFunction<jni.JniResult Function(ffi.Int32)>>(
+              "set_Example_Nested_NestedTwice__ZERO")
+          .asFunction<jni.JniResult Function(int)>();
+
+  /// from: static public int ZERO
+  static int get ZERO => _get_ZERO().integer;
+
+  /// from: static public int ZERO
+  static set ZERO(int value) => _set_ZERO(value).check();
+
+  static final _ctor = jniLookup<ffi.NativeFunction<jni.JniResult Function()>>(
+          "Example_Nested_NestedTwice__ctor")
+      .asFunction<jni.JniResult Function()>();
+
+  /// from: public void <init>()
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  factory Example_Nested_NestedTwice() {
+    return Example_Nested_NestedTwice.fromRef(_ctor().object);
+  }
+}
+
+class $Example_Nested_NestedTwiceType
+    extends jni.JObjType<Example_Nested_NestedTwice> {
+  const $Example_Nested_NestedTwiceType();
+
+  @override
+  String get signature =>
+      r"Lcom/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice;";
+
+  @override
+  Example_Nested_NestedTwice fromRef(jni.JObjectPtr ref) =>
+      Example_Nested_NestedTwice.fromRef(ref);
+
+  @override
+  jni.JObjType get superType => const jni.JObjectType();
+
+  @override
+  final superCount = 1;
+
+  @override
+  int get hashCode => ($Example_Nested_NestedTwiceType).hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    return other.runtimeType == ($Example_Nested_NestedTwiceType) &&
+        other is $Example_Nested_NestedTwiceType;
+  }
+}
+
 /// from: com.github.dart_lang.jnigen.simple_package.Exceptions
 class Exceptions extends jni.JObject {
   @override
diff --git a/pkgs/jnigen/test/simple_package_test/dart_only/dart_bindings/simple_package.dart b/pkgs/jnigen/test/simple_package_test/dart_only/dart_bindings/simple_package.dart
index fb92e1d..3394d3d 100644
--- a/pkgs/jnigen/test/simple_package_test/dart_only/dart_bindings/simple_package.dart
+++ b/pkgs/jnigen/test/simple_package_test/dart_only/dart_bindings/simple_package.dart
@@ -527,6 +527,15 @@
         .newObjectWithArgs(_class.reference, _id_ctor, [value ? 1 : 0]).object);
   }
 
+  static final _id_usesAnonymousInnerClass = jni.Jni.accessors
+      .getMethodIDOf(_class.reference, r"usesAnonymousInnerClass", r"()V");
+
+  /// from: public void usesAnonymousInnerClass()
+  void usesAnonymousInnerClass() {
+    return jni.Jni.accessors.callMethodWithArgs(reference,
+        _id_usesAnonymousInnerClass, jni.JniCallType.voidType, []).check();
+  }
+
   static final _id_getValue =
       jni.Jni.accessors.getMethodIDOf(_class.reference, r"getValue", r"()Z");
 
@@ -574,6 +583,74 @@
   }
 }
 
+/// from: com.github.dart_lang.jnigen.simple_package.Example$Nested$NestedTwice
+class Example_Nested_NestedTwice extends jni.JObject {
+  @override
+  late final jni.JObjType<Example_Nested_NestedTwice> $type = type;
+
+  Example_Nested_NestedTwice.fromRef(
+    jni.JObjectPtr ref,
+  ) : super.fromRef(ref);
+
+  static final _class = jni.Jni.findJClass(
+      r"com/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice");
+
+  /// The type which includes information such as the signature of this class.
+  static const type = $Example_Nested_NestedTwiceType();
+  static final _id_ZERO = jni.Jni.accessors.getStaticFieldIDOf(
+    _class.reference,
+    r"ZERO",
+    r"I",
+  );
+
+  /// from: static public int ZERO
+  static int get ZERO => jni.Jni.accessors
+      .getStaticField(_class.reference, _id_ZERO, jni.JniCallType.intType)
+      .integer;
+
+  /// from: static public int ZERO
+  static set ZERO(int value) =>
+      jni.Jni.env.SetStaticIntField(_class.reference, _id_ZERO, value);
+
+  static final _id_ctor =
+      jni.Jni.accessors.getMethodIDOf(_class.reference, r"<init>", r"()V");
+
+  /// from: public void <init>()
+  /// The returned object must be deleted after use, by calling the `delete` method.
+  factory Example_Nested_NestedTwice() {
+    return Example_Nested_NestedTwice.fromRef(jni.Jni.accessors
+        .newObjectWithArgs(_class.reference, _id_ctor, []).object);
+  }
+}
+
+class $Example_Nested_NestedTwiceType
+    extends jni.JObjType<Example_Nested_NestedTwice> {
+  const $Example_Nested_NestedTwiceType();
+
+  @override
+  String get signature =>
+      r"Lcom/github/dart_lang/jnigen/simple_package/Example$Nested$NestedTwice;";
+
+  @override
+  Example_Nested_NestedTwice fromRef(jni.JObjectPtr ref) =>
+      Example_Nested_NestedTwice.fromRef(ref);
+
+  @override
+  jni.JObjType get superType => const jni.JObjectType();
+
+  @override
+  final superCount = 1;
+
+  @override
+  int get hashCode => ($Example_Nested_NestedTwiceType).hashCode;
+
+  @override
+  bool operator ==(Object other) {
+    return other.runtimeType == ($Example_Nested_NestedTwiceType) &&
+        other is $Example_Nested_NestedTwiceType;
+  }
+}
+
 /// from: com.github.dart_lang.jnigen.simple_package.Exceptions
 class Exceptions extends jni.JObject {
   @override
diff --git a/pkgs/jnigen/test/simple_package_test/java/com/github/dart_lang/jnigen/simple_package/Example.java b/pkgs/jnigen/test/simple_package_test/java/com/github/dart_lang/jnigen/simple_package/Example.java
index edb2748..47fab9d 100644
--- a/pkgs/jnigen/test/simple_package_test/java/com/github/dart_lang/jnigen/simple_package/Example.java
+++ b/pkgs/jnigen/test/simple_package_test/java/com/github/dart_lang/jnigen/simple_package/Example.java
@@ -176,6 +176,17 @@
       this.value = value;
     }
 
+    public void usesAnonymousInnerClass() {
+      new Thread(
+              new Runnable() {
+                @Override
+                public void run() {
+                  System.out.println("2982157093127690243");
+                }
+              })
+          .start();
+    }
+
     public boolean getValue() {
       return value;
     }
@@ -183,5 +194,9 @@
     public void setValue(boolean value) {
       this.value = value;
     }
+
+    public static class NestedTwice {
+      public static int ZERO = 0;
+    }
   }
 }
diff --git a/pkgs/jnigen/test/summary_generation_test.dart b/pkgs/jnigen/test/summary_generation_test.dart
index 0133bac..e1657f1 100644
--- a/pkgs/jnigen/test/summary_generation_test.dart
+++ b/pkgs/jnigen/test/summary_generation_test.dart
@@ -21,6 +21,15 @@
 
 import 'test_util/test_util.dart';
 
+const nestedClasses = [
+  'com.github.dart_lang.jnigen.simple_package.Example\$Nested',
+  'com.github.dart_lang.jnigen.simple_package.Example\$Nested\$NestedTwice',
+  'com.github.dart_lang.jnigen.generics.GrandParent\$StaticParent',
+  'com.github.dart_lang.jnigen.generics.GrandParent\$StaticParent\$Child',
+  'com.github.dart_lang.jnigen.generics.GrandParent\$Parent',
+  'com.github.dart_lang.jnigen.generics.GrandParent\$Parent\$Child',
+];
+
 void expectSummaryHasAllClasses(Classes? classes) {
   expect(classes, isNotNull);
   final decls = classes!.decls;
@@ -28,6 +37,9 @@
   final declNames = decls.keys.toSet();
   final expectedClasses =
       javaClasses.where((name) => !name.contains("annotations.")).toList();
+  // Nested classes should be included automatically with parent class.
+  // change this part if you change this behavior intentionally.
+  expectedClasses.addAll(nestedClasses);
   expect(declNames, containsAll(expectedClasses));
 }