Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Java.Interop] Add .JavaAs() extension method #1234

Merged
merged 7 commits into from
Jul 8, 2024

Conversation

jonpryor
Copy link
Member

@jonpryor jonpryor commented Jun 28, 2024

Fixes: #10
Fixes: dotnet/android#9038

Context: 1adb796

Imagine the following Java type hierarchy:

// Java
public abstract class Drawable {
    public static Drawable createFromStream(IntputStream is, String srcName) {…}
    // …
}
public interface Animatable {
    public void start();
    // …
}
/* package */ class SomeAnimatableDrawable extends Drawable implements Animatable {
    // …
}

Further imagine that a call to Drawable.createFromStream() returns
an instance of SomeAnimatableDrawable.

What does the binding Drawable.CreateFromStream() return?

// C#
var drawable = Drawable.CreateFromStream(input, name);

The binding Drawable.CreateFromStream() look at the runtime type
of the value returned, see that it's of type SomeAnimatableDrawable,
and look for an existing binding of that type. If no such binding is
found -- which will be the case here, as SomeAnimatableDrawable is
package-private -- then we check the value's base class, ad infinitum,
until we hit a type that we do have a binding for (or fail
catastrophically when we can't find a binding for java.lang.Object).
See also TypeManager.CreateInstance(), which is similar to the
code within JniRuntime.JniValueManager.GetPeerConstructor().

Any interfaces implemented by Java value are not consulted. Only
the base class hiearchy.

For the sake of discussion, assume that drawable will be an
instance of DrawableInvoker (e.g. 1adb796), akin to:

internal class DrawableInvoker : Drawable {
    // …
}

Further imagine that we want to invoke Animatable methods on
drawable. How do we do this?

This is where the .JavaCast<TResult>() extension method comes
in: we can use .JavaCast<TResult>() to perform a Java-side type
check the type cast, which returns a value which can be used to
invoke methods on the specified type:

var animatable = drawable.JavaCast<IAnimatable>();
animatable.Start();

The problem with .JavaCast<TResult>() is that it always throws on
failure:

var someOtherIface = drawable.JavaCast<ISomethingElse>();
// throws some exception…

@mattleibow requests an "exception-free JavaCast overload" so that he
can easily use type-specific functionality optionally.

Add the following extension methods on IJavaPeerable:

static class JavaPeerableExtensions {
    public static TResult? JavaAs<TResult>(
            this IJavaPeerable self);
    public static bool TryJavaCast<TResult>(
            this IJavaPeerable self,
            out TResult? result);
}

The .JavaAs<TResult>() extension method mirrors the C# as
operator, returning null if the type coercion would fail.
This makes it useful for one-off invocations:

drawable.JavaAs<IAnimatable>()?.Start();

The .TryJavaCast<TResult>() extension method follows the
TryParse() pattern, returning true if the type coercion succeeds
and the output result parameter is non-null, and false otherwise.
This allows "nicely scoping" things within an if:

if (drawable.TryJavaCast<IAnimatable>(out var animatable)) {
    animatable.Start();
    // …
    animatable.Stop();
}

@jonpryor jonpryor marked this pull request as draft June 28, 2024 20:17
@jonpryor jonpryor requested a review from jpobst June 28, 2024 20:17
@jonpryor
Copy link
Member Author

Will remain a Draft until we create & review a corresponding dotnet/android PR for integration testing…

Copy link
Contributor

@jpobst jpobst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it, I think this will be a useful addition for users.

@@ -11,5 +12,40 @@ public static class JavaPeerableExtensions {
JniPeerMembers.AssertSelf (self);
return JniEnvironment.Types.GetJniTypeNameFromInstance (self.PeerReference);
}

public static bool TryJavaCast<
[DynamicallyAccessedMembers (JavaObject.ConstructorsAndInterfaces)]
Copy link
Contributor

@jpobst jpobst Jun 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing JavaCast<T> only uses [DynamicallyAccessedMembers (Constructors)], should Interfaces be added as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave that to @jonathanpeppers. 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added enough attributes to silence the analyzer's warnings on the original JavaCast<T>().

I would recommend just putting Constructors for now, if no warnings appear.

}

if (!self.PeerReference.IsValid) {
throw new ObjectDisposedException (self.GetType ().FullName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are still cases where we throw exceptions (here and in JniRuntime.JniValueManager.cs). I assume these are corner cases where the object is in a "bad state", but I think I would lean towards never throwing an exception under any circumstance. If the object is bad and we return null, the user cannot use it anyways.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mentally split this into two "important" bits:

  1. Java type checking, and
  2. Things that could never succeed, and would indicate a fundamental error.

The exceptions are for (2). For example, what if someone wanted to use this to convert a Java type to System.ICloneable:

var wat = new Java.Lang.Object().JavaAs<System.ICloneable>();

This would fail to compile right now, due to the generic constraints on JavaAs() (yay), but if someone avoided those constraints (e.g. directly hit JniEnvironment.Runtime.ValueManager.CreatePeer(), use MemberInfo.MakeGenericMethod() at runtime to create an invalid instantiation {untested; maybe that'll throw?}), then I think it would be better to always throw, because the usage is bad.

Fixes: dotnet/android#9038

Context: 1adb796

Imagine the following Java type hierarchy:

	// Java
	public abstract class Drawable {
	    public static Drawable createFromStream(IntputStream is, String srcName) {…}
	    // …
	}
	public interface Animatable {
	    public void start();
	    // …
	}
	/* package */ class SomeAnimatableDrawable extends Drawable implements Animatable {
	    // …
	}

Further imagine that a call to `Drawable.createFromStream()` returns
an instance of `SomeAnimatableDrawable`.

What does the *binding* `Drawable.CreateFromStream()` return?

	// C#
	var drawable = Drawable.CreateFromStream(input, name);

The binding `Drawable.CreateFromStream()` look at the runtime type
of the value returned, see that it's of type `SomeAnimatableDrawable`,
and look for an existing binding of that type.  If no such binding is
found -- which will be the case here, as `SomeAnimatableDrawable` is
package-private -- then we check the value's base class, ad infinitum,
until we hit a type that we *do* have a binding for (or fail
catastrophically when we can't find a binding for `java.lang.Object`).
See also [`TypeManager.CreateInstance()`][0], which is similar to the
code within `JniRuntime.JniValueManager.GetPeerConstructor()`.

Any interfaces implemented by Java value are not consulted.  Only
the base class hiearchy.

For the sake of discussion, assume that `drawable` will be an
instance of `DrawableInvoker` (e.g. 1adb796), akin to:

	internal class DrawableInvoker : Drawable {
	    // …
	}

Further imagine that we want to invoke `Animatable` methods on
`drawable`.  How do we do this?

This is where the [`.JavaCast<TResult>()` extension method][1] comes
in: we can use `.JavaCast<TResult>()` to perform a Java-side type
check the type cast, which returns a value which can be used to
invoke methods on the specified type:

	var animatable = drawable.JavaCast<IAnimatable>();
	animatable.Start();

The problem with `.JavaCast<TResult>()` is that it always throws on
failure:

	var someOtherIface = drawable.JavaCast<ISomethingElse>();
	// throws some exception…

@mattleibow requests an "exception-free JavaCast overload" so that he
can *easily* use type-specific functionality *optionally*.

Add the following extension methods on `IJavaPeerable`:

	static class JavaPeerableExtensions {
	    public static TResult? JavaAs<TResult>(
	            this IJavaPeerable self);
	    public static bool TryJavaCast<TResult>(
	            this IJavaPeerable self,
	            out TResult? result);
	}

The `.JavaAs<TResult>()` extension method mirrors the C# `as`
operator, returning `null` if the type coercion would fail.
This makes it useful for one-off invocations:

	drawable.JavaAs<IAnimatable>()?.Start();

The `.TryJavaCast<TResult>()` extension method follows the
`TryParse()` pattern, returning true if the type coercion succeeds
and the output `result` parameter is non-null, and false otherwise.
This allows "nicely scoping" things within an `if`:

	if (drawable.TryJavaCast<IAnimatable>(out var animatable)) {
	    animatable.Start();
	    // …
	    animatable.Stop();
	}

[0]: https://github.com/dotnet/android/blob/06bb1dc6a292ef5618a3bb6ecca3ca869253ff2e/src/Mono.Android/Java.Interop/TypeManager.cs#L276-L291
[1]: https://github.com/dotnet/android/blob/06bb1dc6a292ef5618a3bb6ecca3ca869253ff2e/src/Mono.Android/Android.Runtime/Extensions.cs#L9-L17
@@ -276,16 +276,45 @@ static Type GetPeerType ([DynamicallyAccessedMembers (Constructors)] Type type)
if (disposed)
throw new ObjectDisposedException (GetType ().Name);

if (!reference.IsValid) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though this check being first means I'm fibbing about "always checking for fundamental errors"; if the value were always null, for whatever reason, we'd never get around to "sanity checking" whether it's even possible. 🤔

jonpryor added a commit to dotnet/android that referenced this pull request Jun 29, 2024
@AmrAlSayed0
Copy link

I guess this PR fixes this issue #10 too

If `IJavaPeerable.PeerReference` isn't valid, return null.
@jonpryor jonpryor marked this pull request as ready for review July 5, 2024 17:05
Comment on lines +45 to +50
[Test]
public void JavaAs_InstanceThatDoesNotImplementInterfaceReturnsNull ()
{
using var v = new MyJavaInterfaceImpl ();
Assert.AreEqual (null, JavaPeerableExtensions.JavaAs<IAndroidInterface> (v));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a test that tries to cast to a random, incorrect type like System.Guid? (One that isn't even a Java thing) Would that case throw?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the case you are suggesting is compiler prevented by the generic constraint?

public static TResult? JavaAs<TResult> (this IJavaPeerable? self)
    where TResult : class, IJavaPeerable
{ ... }

Or perhaps I misunderstand your comment.

Copy link
Member

@jonathanpeppers jonathanpeppers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise, this looks good to me. I just wondered about the one case.

@jonpryor jonpryor merged commit 7a058c0 into main Jul 8, 2024
4 checks passed
@jonpryor jonpryor deleted the dev/jonp/jonp-add-JavaAs branch July 8, 2024 17:49
jonpryor added a commit to dotnet/android that referenced this pull request Jul 8, 2024
jonpryor added a commit to dotnet/android that referenced this pull request Jul 8, 2024
Fixes: #9038

Changes: dotnet/java-interop@ccafbe6...7a058c0

  * dotnet/java-interop@7a058c0e: [Java.Interop] Add `IJavaPeerable.JavaAs()` extension method (dotnet/java-interop#1234)
  * dotnet/java-interop@6f9defa5: Bump to dotnet/android-tools@3debf8e0 (dotnet/java-interop#1236)
  * dotnet/java-interop@6923fb89: [ci] Add dependabot branches to build triggers (dotnet/java-interop#1233)
  * dotnet/java-interop@573028f3: [ci] Use macOS 13 Ventura build agents (dotnet/java-interop#1235)

dotnet/java-interop@7a058c0e adds a new `IJavaPeerable.JavaAs<T>()`
extension method, to perform type casts which return `null` when
the cast will not succeed instead of throwing an exception.

Update `AndroidValueManager.CreatePeer()` to check for Java-side
type compatibility (by way of `TypeManager.CreateInstance()`).
Previously, this would not throw:

	var instance        = …
	var r               = instance.PeerReference;
	var wrap_instance   = JniRuntime.CurrentRuntime.ValueManager.CreatePeer (
	        reference: ref r,
	        options: JniObjectReferenceOptions.Copy,
	        targetType: typeof (SomeInterfaceThatInstanceDoesNotImplement));
	// `wrap_instance` is not null, `.CreatePeer()` does not throw

	wrap_instance.SomeMethod();
	// will throw, due to a type mismatch.

`AndroidValueManager.CreatePeer()` will now return `null` if the
Java instance referenced by the `reference:` parameter is not
convertible to `targetType`, as per [`JNIEnv::IsAssignableFrom()`][0].

[0]: https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#IsAssignableFrom
@github-actions github-actions bot locked and limited conversation to collaborators Aug 9, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Missing exception-free JavaCast overload Type coercion and JavaCast<T> support
4 participants