[jnigen] Add `JLazyReference` and `JFinalString` (https://github.com/dart-lang/jnigen/issues/400)

diff --git a/pkgs/jni/lib/src/jfinal_string.dart b/pkgs/jni/lib/src/jfinal_string.dart
new file mode 100644
index 0000000..ce32799
--- /dev/null
+++ b/pkgs/jni/lib/src/jfinal_string.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:ffi';
+
+import 'jreference.dart';
+import 'lang/jstring.dart';
+import 'third_party/generated_bindings.dart';
+
+/// Used for `static final` Java strings, where the constant string is
+/// available.
+///
+/// If only its value is used using [toDartString], the [reference] is never
+/// populated, saving a method call.
+class JFinalString extends JString with JLazyReference {
+  @override
+  final JObjectPtr Function() lazyReference;
+
+  final String string;
+
+  JFinalString(this.lazyReference, this.string) : super.fromRef(nullptr);
+
+  @override
+  String toDartString({bool releaseOriginal = false}) {
+    if (releaseOriginal) {
+      release();
+    }
+    return string;
+  }
+}
diff --git a/pkgs/jni/lib/src/jreference.dart b/pkgs/jni/lib/src/jreference.dart
index 8d8b71d..093e297 100644
--- a/pkgs/jni/lib/src/jreference.dart
+++ b/pkgs/jni/lib/src/jreference.dart
@@ -29,8 +29,9 @@
   ///
   /// Detaches the finalizer so the underlying pointer will not be deleted.
   JObjectPtr toPointer() {
+    final ref = reference;
     setAsReleased();
-    return _reference;
+    return ref;
   }
 }
 
@@ -42,7 +43,9 @@
       NativeFinalizer(Jni.env.ptr.ref.DeleteGlobalRef.cast());
 
   JReference.fromRef(this._reference) {
-    _finalizer.attach(this, _reference, detach: this);
+    if (_reference != nullptr) {
+      _finalizer.attach(this, _reference, detach: this);
+    }
   }
 
   bool _released = false;
@@ -80,6 +83,45 @@
   void releasedBy(Arena arena) => arena.onReleaseAll(release);
 }
 
+/// Creates a "lazy" [JReference].
+///
+/// The first use of [reference] will call [lazyReference].
+///
+/// This is useful when the Java object is not necessarily used directly, and
+/// there are alternative ways to get a Dart representation of the Object.
+///
+/// Object mixed in with this must call their super.[fromRef] constructor
+/// with [nullptr].
+///
+/// Also see [JFinalString].
+mixin JLazyReference on JReference {
+  JObjectPtr? _lazyReference;
+
+  JObjectPtr Function() get lazyReference;
+
+  @override
+  JObjectPtr get reference {
+    if (_lazyReference == null) {
+      _lazyReference = lazyReference();
+      JReference._finalizer.attach(this, _lazyReference!, detach: this);
+      return _lazyReference!;
+    }
+    if (_released) {
+      throw UseAfterReleaseError();
+    }
+    return _lazyReference!;
+  }
+
+  @override
+  void release() {
+    setAsReleased();
+    if (_lazyReference == null) {
+      return;
+    }
+    Jni.env.DeleteGlobalRef(_lazyReference!);
+  }
+}
+
 extension JReferenceUseExtension<T extends JReference> on T {
   /// Applies [callback] on [this] object and then delete the underlying JNI
   /// reference, returning the result of [callback].
diff --git a/pkgs/jni/test/jfinal_string_test.dart b/pkgs/jni/test/jfinal_string_test.dart
new file mode 100644
index 0000000..c8a9980
--- /dev/null
+++ b/pkgs/jni/test/jfinal_string_test.dart
@@ -0,0 +1,43 @@
+// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:jni/jni.dart';
+import 'package:jni/src/jfinal_string.dart';
+import 'package:test/test.dart';
+
+import 'test_util/test_util.dart';
+
+void main() {
+  // Don't forget to initialize JNI.
+  if (!Platform.isAndroid) {
+    checkDylibIsUpToDate();
+    Jni.spawnIfNotExists(dylibDir: "build/jni_libs", jvmOptions: ["-Xmx128m"]);
+  }
+  run(testRunner: test);
+}
+
+void run({required TestRunnerCallback testRunner}) {
+  testRunner('JFinalString', () {
+    const string = 'abc';
+    var referenceFetchedCount = 0;
+    final finalString = JFinalString(
+      () {
+        ++referenceFetchedCount;
+        return string.toJString().reference;
+      },
+      string,
+    );
+    expect(finalString.toDartString(), string);
+    expect(referenceFetchedCount, 0);
+    expect(finalString.reference, isNot(nullptr));
+    expect(referenceFetchedCount, 1);
+    finalString.reference;
+    expect(referenceFetchedCount, 1);
+    expect(finalString.toDartString(releaseOriginal: true), string);
+    expect(() => finalString.reference, throwsA(isA<UseAfterReleaseError>()));
+    expect(finalString.release, throwsA(isA<DoubleReleaseError>()));
+  });
+}