diff --git a/src/ComposeNet.Compose/Compose.cs b/src/ComposeNet.Compose/Compose.cs index 97dd44d5..ff8fc21d 100644 --- a/src/ComposeNet.Compose/Compose.cs +++ b/src/ComposeNet.Compose/Compose.cs @@ -294,6 +294,215 @@ static T RememberSaveableWrapper(System.Func factory, object?[]? keys, ICo } } + /// + /// Compose's SideEffect { … }: runs + /// on every successful recomposition, after the composition + /// has been applied. Use it to publish managed-side state into + /// objects that aren't managed by Compose (e.g. logging, analytics, + /// pushing a value into a non-Compose Android API). + /// + /// + /// + /// must not mutate any state that + /// the same composition reads — that would invalidate the + /// composition Compose just applied and trigger an infinite + /// recomposition loop. + /// + /// + /// Stale captures: closures inside see + /// whatever the C# closure captured during the most recent render + /// — SideEffect bodies are recreated every render so this + /// is generally what you want. + /// + /// + public static void SideEffect(System.Action effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.SideEffect must be called inside a composition (e.g. inside a SetContent body or a ComposableNode.Render override)."); + + EffectsKt.SideEffect(new ComposableLambda0(effect), composer, _changed: 0); + } + + /// + /// Compose's DisposableEffect(key1) { … onDispose { … } }: + /// runs the first time this call site + /// is composed (and again whenever changes), + /// and calls the returned cleanup on + /// key change or when the call site leaves the composition. + /// + /// + /// Compose compares this against the previous value using + /// Object.equals via the boxed Java value. Supports + /// null, any peer, + /// , and every common .NET primitive. + /// + /// + /// Setup callback. Must return a non-null cleanup + /// — use () => { } when + /// there's nothing to clean up. + /// + /// + /// Stale captures: closures inside see + /// whatever the C# closure captured when the effect was last + /// set up. To observe newer values without invalidating the + /// effect, hoist them into . + /// + public static void DisposableEffect( + object? key1, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.DisposableEffect must be called inside a composition."); + + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Two-key overload of + /// . + /// + public static void DisposableEffect( + object? key1, + object? key2, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.DisposableEffect must be called inside a composition."); + + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Three-key overload of + /// . + /// + public static void DisposableEffect( + object? key1, + object? key2, + object? key3, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.DisposableEffect must be called inside a composition."); + + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + ComposeBridges.BoxKey(key3), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Compose's LaunchedEffect(key1) { … }: launches the C# + /// as a coroutine the first time this call + /// site is composed (and again whenever + /// changes). The previous launch is cancelled on key change or + /// when the call site leaves the composition — the + /// passed to + /// is signalled, and the body should + /// observe it (e.g. via ). + /// + /// + /// Compose compares this against the previous value using + /// Object.equals via the boxed Java value. Pass a stable + /// "version" (e.g. stringified, or + /// the literal "once") when you want the body to run + /// exactly once per call-site lifetime. + /// + /// + /// The async work to run. Honours the supplied + /// when the + /// underlying Kotlin Job is cancelled. + /// + /// + /// + /// Stale captures: closures inside see + /// whatever the C# closure captured when the launch started. To + /// observe newer values without restarting the body, read from + /// . + /// + /// + public static void LaunchedEffect( + object? key1, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.LaunchedEffect must be called inside a composition."); + + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + + /// + /// Two-key overload of + /// . + /// + public static void LaunchedEffect( + object? key1, + object? key2, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.LaunchedEffect must be called inside a composition."); + + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + + /// + /// Three-key overload of + /// . + /// + public static void LaunchedEffect( + object? key1, + object? key2, + object? key3, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + var composer = ComposeContext.Current + ?? throw new System.InvalidOperationException( + "Compose.LaunchedEffect must be called inside a composition."); + + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + ComposeBridges.BoxKey(key3), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + /// /// Compose's derivedStateOf { calculation() }: returns a /// read-only whose value is lazily diff --git a/src/ComposeNet.Compose/DisposableEffect.cs b/src/ComposeNet.Compose/DisposableEffect.cs new file mode 100644 index 00000000..58e451c6 --- /dev/null +++ b/src/ComposeNet.Compose/DisposableEffect.cs @@ -0,0 +1,67 @@ +using AndroidX.Compose.Runtime; + +namespace ComposeNet; + +/// +/// Tree-syntax wrapper around +/// . +/// Re-runs Effect on first composition and any time +/// Key1 / Key2 / Key3 changes; calls the cleanup +/// on key change or when this node leaves +/// the composition. +/// +/// +/// +/// new DisposableEffect(_sensorId, scope => +/// { +/// var registration = SensorRegistry.Subscribe(_sensorId, OnSensorTick); +/// return () => registration.Dispose(); +/// }) +/// +/// +public sealed class DisposableEffect : ComposableNode +{ + readonly object? _key1, _key2, _key3; + readonly int _keyCount; + readonly System.Func _effect; + + /// Single-key form. + public DisposableEffect( + object? key1, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _keyCount = 1; _effect = effect; + } + + /// Two-key form. + public DisposableEffect( + object? key1, + object? key2, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _key2 = key2; _keyCount = 2; _effect = effect; + } + + /// Three-key form. + public DisposableEffect( + object? key1, + object? key2, + object? key3, + System.Func effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _key2 = key2; _key3 = key3; _keyCount = 3; _effect = effect; + } + + internal override void Render(IComposer composer) + { + switch (_keyCount) + { + case 1: Compose.DisposableEffect(_key1, _effect); break; + case 2: Compose.DisposableEffect(_key1, _key2, _effect); break; + default: Compose.DisposableEffect(_key1, _key2, _key3, _effect); break; + } + } +} diff --git a/src/ComposeNet.Compose/DisposableEffectBody.cs b/src/ComposeNet.Compose/DisposableEffectBody.cs new file mode 100644 index 00000000..ba911134 --- /dev/null +++ b/src/ComposeNet.Compose/DisposableEffectBody.cs @@ -0,0 +1,64 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace ComposeNet; + +/// +/// JCW for Compose's DisposableEffect body — +/// Function1<DisposableEffectScope, DisposableEffectResult>. +/// Kotlin invokes the body once per (key change | enter composition) +/// and stores the returned ; when +/// keys change or the call site leaves the composition Compose calls +/// on the stored result. +/// +/// +/// The C# body returns a "onDispose" +/// callback. We wrap that callback in a fresh +/// and hand it to +/// , which +/// is the only way to construct an +/// without subclassing the interface in Java. +/// +[Register("composenet/compose/DisposableEffectBody")] +internal sealed class DisposableEffectBody : Java.Lang.Object, IFunction1 +{ + readonly System.Func _body; + + public DisposableEffectBody(System.Func body) + { + _body = body; + } + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + // Compose always passes a non-null DisposableEffectScope (the + // singleton `InternalDisposableEffectScope`). Use JavaCast + // rather than `p0 as DisposableEffectScope` because Mono.Android's + // peer cache doesn't know Kotlin's `internal object` subclasses + // implement the bound interface — a plain `as` returns null even + // when the underlying Java object does implement the interface. + if (p0 is null) + throw new System.InvalidOperationException( + "DisposableEffect body received a null DisposableEffectScope"); + + DisposableEffectScope scope; + try + { + scope = p0.JavaCast(); + } + catch (System.Exception ex) + { + throw new System.InvalidOperationException( + "DisposableEffect body could not project arg (" + + (p0.Class?.Name ?? "") + + ") as DisposableEffectScope", ex); + } + + var onDispose = _body(scope) + ?? throw new System.InvalidOperationException( + "DisposableEffect body returned a null onDispose callback. " + + "Return `() => { }` if there's nothing to clean up."); + return (Java.Lang.Object)scope.OnDispose(new ComposableLambda0(onDispose)); + } +} diff --git a/src/ComposeNet.Compose/EffectsBridges.cs b/src/ComposeNet.Compose/EffectsBridges.cs new file mode 100644 index 00000000..7be96193 --- /dev/null +++ b/src/ComposeNet.Compose/EffectsBridges.cs @@ -0,0 +1,74 @@ +using Android.Runtime; + +namespace ComposeNet; + +// Raw-JNI helpers for the Compose effect APIs in +// `androidx.compose.runtime.EffectsKt`. The entry points themselves +// (`SideEffect`, `DisposableEffect`, `LaunchedEffect`, +// `RememberCoroutineScope`) are bound directly by the +// `Xamarin.AndroidX.Compose.Runtime.Android` NuGet, so we don't need +// `[ComposeBridge]` shims for them. What we *do* need is: +// +// - `BoxKey(object?)` — non-generic equivalent of +// `MutableState.ToJava`, so the public C# effect APIs can take +// `object?` keys and box primitives once per render. +// +// `kotlin.Result` plumbing (constructing/reading `Result.Failure`) +// lives in , shared with `SuspendBridge`. +internal static partial class ComposeBridges +{ + /// + /// Non-generic mirror of for + /// boxing an opaque key into the + /// the EffectsKt entry points expect. + /// Supports null, any peer, + /// , and every common .NET primitive. + /// + internal static Java.Lang.Object? BoxKey(object? key) => key switch + { + null => null, + Java.Lang.Object o => o, + string s => new Java.Lang.String(s), + bool b => Java.Lang.Boolean.ValueOf(b), + char c => Java.Lang.Character.ValueOf(c), + sbyte sb => Java.Lang.Byte.ValueOf(sb), + byte by => Java.Lang.Short.ValueOf((short)by), + short sh => Java.Lang.Short.ValueOf(sh), + ushort us => Java.Lang.Integer.ValueOf(us), + int i => Java.Lang.Integer.ValueOf(i), + uint ui => Java.Lang.Long.ValueOf(ui), + long l => Java.Lang.Long.ValueOf(l), + ulong ul => Java.Lang.Long.ValueOf(unchecked((long)ul)), + float f => Java.Lang.Float.ValueOf(f), + double d => Java.Lang.Double.ValueOf(d), + // Anything else has no obvious mapping to Java's `Object.equals` + // semantics that Compose uses for key comparison. Boxed value + // types (Guid, DateTime, enums, records) can produce a fresh + // identity hash every render — silently triggering an effect + // restart on every recomposition. Refuse and let the caller + // pick a stable representation (a string, a primitive, or a + // pre-allocated Java.Lang.Object peer). + _ => throw new System.NotSupportedException( + $"Compose effect key type '{key.GetType().FullName}' is not supported. " + + "Use a Java.Lang.Object, string, bool, char, sbyte/byte/short/ushort/int/uint/long/ulong/float/double, " + + "or convert your key to a stable form (e.g. ToString() or a stable hash).") + }; + + /// + /// Box an array of ? keys into a Java + /// Object[] for the vararg keys overload of + /// + /// / + /// . + /// Currently unused — the 1/2/3-key overloads cover the common + /// case and avoid the per-render array allocation. Kept here for + /// the future variadic public overload. + /// + internal static Java.Lang.Object?[] BoxKeyArray(object?[] keys) + { + var boxed = new Java.Lang.Object?[keys.Length]; + for (int i = 0; i < keys.Length; i++) + boxed[i] = BoxKey(keys[i]); + return boxed; + } +} diff --git a/src/ComposeNet.Compose/JobCompletionHandler.cs b/src/ComposeNet.Compose/JobCompletionHandler.cs new file mode 100644 index 00000000..e1079cc6 --- /dev/null +++ b/src/ComposeNet.Compose/JobCompletionHandler.cs @@ -0,0 +1,43 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace ComposeNet; + +/// +/// JCW for Function1<Throwable?, Unit> registered via +/// . +/// Compose invokes this handler when the launched coroutine's job +/// transitions into the cancelling state, so we can propagate +/// cancellation into the C# +/// that the user's Func<CancellationToken, Task> body +/// observes. +/// +[Register("composenet/compose/JobCompletionHandler")] +internal sealed class JobCompletionHandler : Java.Lang.Object, IFunction1 +{ + readonly System.Threading.CancellationTokenSource _cts; + + public JobCompletionHandler(System.Threading.CancellationTokenSource cts) + { + _cts = cts; + } + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + // p0 is the Throwable cause (or null for normal completion). + // Either way we cancel the CTS — the user's Task body sees + // ct.IsCancellationRequested and exits cooperatively. + try + { + _cts.Cancel(); + } + catch + { + // Cancel is best-effort. A racing Dispose on the CTS or + // an exception raised by a registered callback should + // never propagate out into the JVM (Kotlin would crash + // the process with an UnhandledException). Swallow. + } + return global::Kotlin.Unit.Instance!; + } +} diff --git a/src/ComposeNet.Compose/KotlinResult.cs b/src/ComposeNet.Compose/KotlinResult.cs new file mode 100644 index 00000000..221bfacc --- /dev/null +++ b/src/ComposeNet.Compose/KotlinResult.cs @@ -0,0 +1,172 @@ +using System; +using Android.Runtime; + +namespace ComposeNet; + +/// +/// Raw-JNI helpers for Kotlin's kotlin.Result type — the boxed +/// success/failure wrapper Kotlin coroutines pass through +/// Continuation.resumeWith. +/// +/// +/// +/// Both ends of the bridge live here: +/// +/// +/// +/// — call kotlin.ResultKt.createFailure(Throwable) to wrap a C# +/// exception in a Result.Failure for +/// Continuation.resumeWith (used by +/// and any other C#→Kotlin suspend +/// resume site). +/// + +/// — peek at a +/// resumed Result handle to detect a failure box and extract +/// the underlying Throwable for the awaiting C# task (used by +/// ). +/// +/// +/// Why raw JNI: kotlin.Result is a @JvmInline value class +/// wrapping Object, so every accessor on it (and every helper +/// on ResultKt) emits a mangled JVM name (Result-impl, +/// createFailure-impl, isFailure-impl, +/// exceptionOrNull-impl) that the binder strips — same root +/// cause as +/// dotnet/java-interop#1440. +/// The nested Result$Failure class is intentionally +/// internal in Kotlin source and isn't surfaced by the binder +/// regardless. When #1440 lands and the binder restores +/// ResultKt.createFailure(Throwable) / throwOnFailure(Object) +/// / etc., the bodies of these helpers can be replaced with calls to +/// the bound members and the cached field/method ids deleted. +/// +/// +/// Caching strategy: JNIEnv.FindClass / +/// JNIEnv.GetStaticMethodID / JNIEnv.GetFieldID +/// are pure functions of their string args, and Mono.Android's +/// FindClass returns a stable, globally registered class ref +/// — no NewGlobalRef/DeleteLocalRef dance. Multi-thread +/// racers all observe the same ids; losing the race just re-writes +/// identical values, so plain stores are fine. +/// +/// +internal static class KotlinResult +{ + // kotlin/ResultKt — host of the static `createFailure(Throwable): Object` helper. + static IntPtr s_resultKtClass; + static IntPtr s_resultKtCreateFailureMethod; + + // kotlin/Result$Failure — the boxed-failure type Kotlin uses to + // distinguish failure resumes from success resumes. Its `exception` + // field carries the underlying Throwable. + static IntPtr s_resultFailureClass; + static IntPtr s_resultFailureExceptionField; + + /// + /// Construct a kotlin.Result.Failure(throwable) instance — + /// the boxed-failure form a + /// expects when something goes wrong inside its suspend body. + /// + /// + /// The Java throwable to wrap; non-null. C# exceptions can be + /// converted via + /// (or any helper that produces a ). + /// + /// + /// A managed peer wrapping the Kotlin failure object. The caller + /// passes this directly to continuation.ResumeWith(result). + /// + internal static unsafe Java.Lang.Object CreateFailure(Java.Lang.Throwable throwable) + { + ArgumentNullException.ThrowIfNull(throwable); + + EnsureResultKt(); + + try + { + JValue* args = stackalloc JValue[1]; + args[0] = new JValue(throwable); + var handle = JNIEnv.CallStaticObjectMethod( + s_resultKtClass, s_resultKtCreateFailureMethod, args); + // Transfer the local ref into a managed peer; the caller + // hands the peer to Continuation.ResumeWith which keeps it + // alive until the coroutine actually resumes. + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + GC.KeepAlive(throwable); + } + } + + /// + /// Returns if + /// refers to a kotlin.Result.Failure instance — i.e. the + /// resumed value represents a failure rather than a success. + /// + /// + /// Raw JNI handle to test. is treated as + /// "not a failure" (a null resume value is a successful null). + /// + internal static bool IsFailure(IntPtr handle) + { + if (handle == IntPtr.Zero) return false; + EnsureResultFailure(); + return JNIEnv.IsInstanceOf(handle, s_resultFailureClass); + } + + /// + /// Extract the underlying from a + /// kotlin.Result.Failure peer. + /// + /// + /// A peer wrapping a Result.Failure handle. Must have been + /// vetted with first. + /// + /// + /// The wrapped throwable as a + /// ( derives from + /// on Mono.Android, so callers can + /// catch (Exception) or pattern-match on the Java type). + /// + internal static Exception ExtractException(Java.Lang.Object failure) + { + ArgumentNullException.ThrowIfNull(failure); + // s_resultFailureExceptionField is guaranteed populated: + // IsFailure is the only sanctioned predicate, and it calls + // EnsureResultFailure before this method ever runs. + IntPtr exHandle = JNIEnv.GetObjectField(failure.Handle, s_resultFailureExceptionField); + try + { + var th = Java.Lang.Object.GetObject( + exHandle, JniHandleOwnership.TransferLocalRef); + if (th is not null) + return th; + return new InvalidOperationException( + "Kotlin suspend call failed with a null Throwable in Result.Failure"); + } + finally + { + GC.KeepAlive(failure); + } + } + + static void EnsureResultKt() + { + if (s_resultKtCreateFailureMethod != IntPtr.Zero) return; + s_resultKtClass = JNIEnv.FindClass("kotlin/ResultKt"); + s_resultKtCreateFailureMethod = JNIEnv.GetStaticMethodID( + s_resultKtClass, + "createFailure", + "(Ljava/lang/Throwable;)Ljava/lang/Object;"); + } + + static void EnsureResultFailure() + { + if (s_resultFailureClass != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("kotlin/Result$Failure"); + s_resultFailureExceptionField = JNIEnv.GetFieldID(cls, "exception", "Ljava/lang/Throwable;"); + s_resultFailureClass = cls; + } +} diff --git a/src/ComposeNet.Compose/LaunchedEffect.cs b/src/ComposeNet.Compose/LaunchedEffect.cs new file mode 100644 index 00000000..551cf44e --- /dev/null +++ b/src/ComposeNet.Compose/LaunchedEffect.cs @@ -0,0 +1,70 @@ +using AndroidX.Compose.Runtime; + +namespace ComposeNet; + +/// +/// Tree-syntax wrapper around +/// . +/// Launches Body as a C# +/// on first composition and any time a key changes; cancels the +/// supplied on key +/// change or when this node leaves the composition. +/// +/// +/// +/// new LaunchedEffect(_sessionId, async ct => +/// { +/// while (!ct.IsCancellationRequested) +/// { +/// await Task.Delay(1000, ct); +/// _ticks.Value++; +/// } +/// }) +/// +/// +public sealed class LaunchedEffect : ComposableNode +{ + readonly object? _key1, _key2, _key3; + readonly int _keyCount; + readonly System.Func _body; + + /// Single-key form. + public LaunchedEffect( + object? key1, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _keyCount = 1; _body = body; + } + + /// Two-key form. + public LaunchedEffect( + object? key1, + object? key2, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _key2 = key2; _keyCount = 2; _body = body; + } + + /// Three-key form. + public LaunchedEffect( + object? key1, + object? key2, + object? key3, + System.Func body) + { + System.ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _key2 = key2; _key3 = key3; _keyCount = 3; _body = body; + } + + internal override void Render(IComposer composer) + { + switch (_keyCount) + { + case 1: Compose.LaunchedEffect(_key1, _body); break; + case 2: Compose.LaunchedEffect(_key1, _key2, _body); break; + default: Compose.LaunchedEffect(_key1, _key2, _key3, _body); break; + } + } +} diff --git a/src/ComposeNet.Compose/LaunchedEffectBody.cs b/src/ComposeNet.Compose/LaunchedEffectBody.cs new file mode 100644 index 00000000..f5167d49 --- /dev/null +++ b/src/ComposeNet.Compose/LaunchedEffectBody.cs @@ -0,0 +1,248 @@ +using Android.Runtime; +using Kotlin.Coroutines; +using Kotlin.Coroutines.Intrinsics; +using Kotlin.Jvm.Functions; +using Xamarin.KotlinX.Coroutines; + +namespace ComposeNet; + +/// +/// JCW for the suspend lambda Kotlin's LaunchedEffect expects: +/// Function2<CoroutineScope, Continuation<in Unit>, Any?>. +/// Bridges the Kotlin coroutine to a C# async Task body that +/// accepts a . +/// +/// +/// +/// The coroutine protocol contract: Invoke must either return +/// the final boxed result (success / failure) synchronously, +/// or return the +/// sentinel and resume +/// the supplied later. This implementation +/// always returns the sentinel and resumes from a +/// +/// once the C# completes, +/// even when the body completes synchronously — that keeps the +/// resume protocol uniform and lets Kotlin's dispatcher own thread +/// affinity for the resume side. +/// +/// +/// Cancellation flow: +/// +/// +/// Allocate a CTS for this invocation. +/// Register a 3-arg InvokeOnCompletion(onCancelling: true, invokeImmediately: true, handler) +/// hook on the job — the 1-arg overload only fires after completion +/// and is too late to cancel a still-suspended Task. +/// Invoke the user's +/// Func<CancellationToken, Task> with cts.Token. +/// Once the resulting Task completes (sync or async), +/// resume the continuation with Unit on success / +/// Result.Failure(throwable) on fault. An +/// +/// once-gate keeps the resume from firing twice if the Job is +/// cancelled while the ContinueWith is still racing. +/// +/// +[Register("composenet/compose/LaunchedEffectBody")] +internal sealed class LaunchedEffectBody : Java.Lang.Object, IFunction2 +{ + readonly System.Func _body; + + public LaunchedEffectBody( + System.Func body) + { + _body = body; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1) + { + // p0 is CoroutineScope (unused — we have a managed Task). + // p1 is the Continuation we resume when the Task ends. + // Kotlin hands us an anonymous synthetic class (e.g. + // `IntrinsicsKt__IntrinsicsJvmKt$createCoroutineUnintercepted$$inlined$...$4`) + // that does implement `kotlin.coroutines.Continuation` but isn't + // in Mono.Android's peer registry, so a plain `as IContinuation` + // cast returns null. JavaCast bypasses the managed-type + // cache and synthesizes an interface peer from the raw handle, + // which is exactly what we need. + if (p1 is null) + throw new System.InvalidOperationException( + "LaunchedEffect Invoke received a null Continuation in slot 1"); + + IContinuation continuation; + try + { + continuation = p1.JavaCast(); + } + catch (System.Exception ex) + { + throw new System.InvalidOperationException( + "LaunchedEffect Invoke could not project slot 1 (" + + (p1.Class?.Name ?? "") + + ") as kotlin.coroutines.Continuation", ex); + } + + var cts = new System.Threading.CancellationTokenSource(); + var resumed = new ResumeOnceGate(); + + // Bind the Job's cancellation → our CTS so the user's Task can + // observe ct.IsCancellationRequested and bail cooperatively. + // The IDisposableHandle is held strongly through `state` below + // so it doesn't get yanked by GC before completion. + var handler = new JobCompletionHandler(cts); + Xamarin.KotlinX.Coroutines.IDisposableHandle? completionRegistration = null; + try + { + var job = JobKt.GetJob(continuation.Context); + completionRegistration = job.InvokeOnCompletion( + onCancelling: true, invokeImmediately: true, handler); + } + catch (System.Exception ex) + { + // Without a Job in the context we can't wire cancellation + // — but the user's Task can still run. Log and continue. + System.Diagnostics.Debug.WriteLine( + "ComposeNet.LaunchedEffect: failed to register Job completion handler: " + ex); + } + + System.Threading.Tasks.Task task; + try + { + task = _body(cts.Token) ?? System.Threading.Tasks.Task.CompletedTask; + } + catch (System.Exception ex) + { + // Synchronous fault from the body (rare — Func bodies + // typically wrap their work in async). Throw a Java + // RuntimeException; Kotlin's coroutine machinery converts it + // into a Result.Failure for any waiting awaiters of the job + // and the InvokeOnCompletion hook still fires. Include + // ex.ToString() so the original stack trace + type stay + // visible in logcat / coroutine debug output. + completionRegistration?.Dispose(); + cts.Dispose(); + handler.Dispose(); + throw new Java.Lang.RuntimeException( + "LaunchedEffect body threw synchronously: " + ex); + } + + // Always go through ContinueWith so the resume runs on the + // default TaskScheduler rather than inlining on whatever thread + // happened to complete the Task. This keeps lifetime tracking + // simple and matches what Kotlin's own `kotlinx-coroutines` + // does when handed an external future. + var ctx = new ResumeContext( + continuation, cts, handler, completionRegistration, resumed); + task.ContinueWith( + static (t, state) => + { + var c = (ResumeContext)state!; + c.Resume(t); + }, + ctx, + System.Threading.CancellationToken.None, + System.Threading.Tasks.TaskContinuationOptions.None, + System.Threading.Tasks.TaskScheduler.Default); + + return IntrinsicsKt.COROUTINE_SUSPENDED; + } + + // Holds everything we need to safely resume the continuation + // exactly once and release the JCWs / handler registration after. + sealed class ResumeContext + { + readonly IContinuation _continuation; + readonly System.Threading.CancellationTokenSource _cts; + readonly JobCompletionHandler _handler; + readonly Xamarin.KotlinX.Coroutines.IDisposableHandle? _completionRegistration; + readonly ResumeOnceGate _gate; + + public ResumeContext( + IContinuation continuation, + System.Threading.CancellationTokenSource cts, + JobCompletionHandler handler, + Xamarin.KotlinX.Coroutines.IDisposableHandle? completionRegistration, + ResumeOnceGate gate) + { + _continuation = continuation; + _cts = cts; + _handler = handler; + _completionRegistration = completionRegistration; + _gate = gate; + } + + public void Resume(System.Threading.Tasks.Task t) + { + if (!_gate.TryEnter()) + return; + + try + { + Java.Lang.Object? result; + if (t.IsFaulted) + { + var inner = t.Exception?.GetBaseException() + ?? new System.Exception("LaunchedEffect Task faulted with no exception"); + var throwable = ToThrowable(inner); + result = KotlinResult.CreateFailure(throwable); + } + else if (t.IsCanceled) + { + // Surface cancellation as Result.Failure(CancellationException) + // so Kotlin's coroutine machinery records this as a + // cancellation rather than treating it as success. + // kotlinx.coroutines.CancellationException is a + // typealias for java.util.concurrent.CancellationException + // on JVM, so the simpler Java type is fine. + var ce = new Java.Util.Concurrent.CancellationException( + "LaunchedEffect Task was cancelled"); + result = KotlinResult.CreateFailure(ce); + } + else + { + result = global::Kotlin.Unit.Instance!; + } + + try + { + _continuation.ResumeWith(result); + } + catch (System.Exception ex) + { + // ResumeWith on an already-cancelled continuation + // may throw IllegalStateException from Kotlin's + // dispatched continuation impl. Log and swallow — + // there's no caller to surface this to. + System.Diagnostics.Debug.WriteLine( + "ComposeNet.LaunchedEffect: continuation resume failed: " + ex); + } + } + finally + { + try { _completionRegistration?.Dispose(); } catch { } + try { _cts.Dispose(); } catch { } + // Don't dispose _handler — Kotlin's job machinery may + // still hold a reference to it briefly. Let GC reclaim + // the JCW once Kotlin drops it. + System.GC.KeepAlive(_handler); + } + } + + static Java.Lang.Throwable ToThrowable(System.Exception ex) => + ex switch + { + Java.Lang.Throwable th => th, + System.OperationCanceledException => + new Java.Util.Concurrent.CancellationException(ex.Message ?? "cancelled"), + _ => new Java.Lang.RuntimeException(ex.GetType().Name + ": " + ex.Message), + }; + } + + sealed class ResumeOnceGate + { + int _state; // 0 = pending, 1 = resumed + public bool TryEnter() => + System.Threading.Interlocked.CompareExchange(ref _state, 1, 0) == 0; + } +} diff --git a/src/ComposeNet.Compose/PublicAPI.Unshipped.txt b/src/ComposeNet.Compose/PublicAPI.Unshipped.txt index 3d9c57a6..b15dc80a 100644 --- a/src/ComposeNet.Compose/PublicAPI.Unshipped.txt +++ b/src/ComposeNet.Compose/PublicAPI.Unshipped.txt @@ -163,6 +163,10 @@ ComposeNet.DismissibleNavigationDrawer.Drawer.get -> ComposeNet.ComposableNode? ComposeNet.DismissibleNavigationDrawer.Drawer.set -> void ComposeNet.DismissibleNavigationDrawer.InitiallyOpen.get -> bool ComposeNet.DismissibleNavigationDrawer.InitiallyOpen.set -> void +ComposeNet.DisposableEffect +ComposeNet.DisposableEffect.DisposableEffect(object? key1, object? key2, object? key3, System.Func! effect) -> void +ComposeNet.DisposableEffect.DisposableEffect(object? key1, object? key2, System.Func! effect) -> void +ComposeNet.DisposableEffect.DisposableEffect(object? key1, System.Func! effect) -> void ComposeNet.DockedSearchBar ComposeNet.DockedSearchBar.DockedSearchBar(bool expanded, System.Action! onExpandedChange) -> void ComposeNet.DockedSearchBar.InputField.get -> ComposeNet.ComposableNode? @@ -345,6 +349,10 @@ ComposeNet.LargeTopAppBar.NavigationIcon.get -> ComposeNet.ComposableNode? ComposeNet.LargeTopAppBar.NavigationIcon.set -> void ComposeNet.LargeTopAppBar.Title.get -> ComposeNet.ComposableNode? ComposeNet.LargeTopAppBar.Title.set -> void +ComposeNet.LaunchedEffect +ComposeNet.LaunchedEffect.LaunchedEffect(object? key1, object? key2, object? key3, System.Func! body) -> void +ComposeNet.LaunchedEffect.LaunchedEffect(object? key1, object? key2, System.Func! body) -> void +ComposeNet.LaunchedEffect.LaunchedEffect(object? key1, System.Func! body) -> void ComposeNet.LazyColumn ComposeNet.LazyColumn.LazyColumn(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void ComposeNet.LazyColumn.State.get -> AndroidX.Compose.Foundation.Lazy.LazyListState? @@ -733,6 +741,8 @@ ComposeNet.SegmentedButton.Icon.set -> void ComposeNet.SegmentedButton.SegmentedButton(bool checked, System.Action! onCheckedChange) -> void ComposeNet.SegmentedButton.SegmentedButton(bool selected, System.Action! onClick) -> void ComposeNet.Shape +ComposeNet.SideEffect +ComposeNet.SideEffect.SideEffect(System.Action! effect) -> void ComposeNet.SingleChoiceSegmentedButtonRow ComposeNet.SingleChoiceSegmentedButtonRow.SingleChoiceSegmentedButtonRow() -> void ComposeNet.Slider @@ -947,6 +957,12 @@ static ComposeNet.Arrangement.SpaceEvenly.get -> ComposeNet.Arrangement! static ComposeNet.Arrangement.Start.get -> ComposeNet.Arrangement! static ComposeNet.Arrangement.Top.get -> ComposeNet.Arrangement! static ComposeNet.Compose.DerivedStateOf(System.Func! calculation) -> ComposeNet.DerivedState! +static ComposeNet.Compose.DisposableEffect(object? key1, object? key2, object? key3, System.Func! effect) -> void +static ComposeNet.Compose.DisposableEffect(object? key1, object? key2, System.Func! effect) -> void +static ComposeNet.Compose.DisposableEffect(object? key1, System.Func! effect) -> void +static ComposeNet.Compose.LaunchedEffect(object? key1, object? key2, object? key3, System.Func! body) -> void +static ComposeNet.Compose.LaunchedEffect(object? key1, object? key2, System.Func! body) -> void +static ComposeNet.Compose.LaunchedEffect(object? key1, System.Func! body) -> void static ComposeNet.Compose.ProduceState(T initialValue, object? key1, object? key2, object? key3, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> ComposeNet.MutableState! static ComposeNet.Compose.ProduceState(T initialValue, object? key1, object? key2, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> ComposeNet.MutableState! static ComposeNet.Compose.ProduceState(T initialValue, object? key1, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> ComposeNet.MutableState! @@ -962,6 +978,7 @@ static ComposeNet.Compose.RememberSaveable(System.Func! factory, object? k static ComposeNet.Compose.RememberSaveable(System.Func! factory, object? key1, object? key2, int line = 0, string! file = "") -> T static ComposeNet.Compose.RememberSaveable(System.Func! factory, object? key1, object? key2, object? key3, int line = 0, string! file = "") -> T static ComposeNet.Compose.RememberSaveableKeyed(System.Func! factory, object?[]! keys, int line = 0, string! file = "") -> T +static ComposeNet.Compose.SideEffect(System.Action! effect) -> void static ComposeNet.Dp.implicit operator ComposeNet.Dp(float value) -> ComposeNet.Dp static ComposeNet.Dp.implicit operator ComposeNet.Dp(int value) -> ComposeNet.Dp static ComposeNet.Dp.operator !=(ComposeNet.Dp left, ComposeNet.Dp right) -> bool diff --git a/src/ComposeNet.Compose/SideEffect.cs b/src/ComposeNet.Compose/SideEffect.cs new file mode 100644 index 00000000..533fcbe8 --- /dev/null +++ b/src/ComposeNet.Compose/SideEffect.cs @@ -0,0 +1,34 @@ +using AndroidX.Compose.Runtime; + +namespace ComposeNet; + +/// +/// Tree-syntax wrapper around . +/// Add to a container to run a callback on every successful +/// recomposition of that container, after the composition has been +/// applied. Useful when you'd rather express the effect as a sibling +/// in your composable tree than as a statement inside a custom +/// Render override: +/// +/// new Column +/// { +/// new Text(_count.Value.ToString()), +/// new SideEffect(() => Log.Info("rendered count = " + _count.Value)), +/// } +/// +/// +public sealed class SideEffect : ComposableNode +{ + readonly System.Action _effect; + + /// Create the node. runs + /// every successful recomposition. + public SideEffect(System.Action effect) + { + System.ArgumentNullException.ThrowIfNull(effect); + _effect = effect; + } + + internal override void Render(IComposer composer) + => Compose.SideEffect(_effect); +} diff --git a/src/ComposeNet.Compose/SuspendBridge.cs b/src/ComposeNet.Compose/SuspendBridge.cs index d37e1222..37d6c9d3 100644 --- a/src/ComposeNet.Compose/SuspendBridge.cs +++ b/src/ComposeNet.Compose/SuspendBridge.cs @@ -47,13 +47,6 @@ internal static class SuspendBridge // JNI ref types). static IntPtr s_suspendedHandle; - // Cached global ref + field id for kotlin/Result$Failure (the - // wrapper for failed suspend results). Kotlin.ResultKt's static - // methods are NOT bound in Xamarin.Kotlin.StdLib 2.3.21, so we - // detect failures via raw JNI instead. - static IntPtr s_resultFailureClass; - static IntPtr s_resultFailureExceptionField; - /// /// Call a Kotlin suspend function and project its boxed /// result through into a typed @@ -139,8 +132,8 @@ public static Task Invoke( var boxed = t.GetAwaiter().GetResult(); try { - if (boxed is not null && IsResultFailure(boxed.Handle)) - throw ExtractFailureException(boxed); + if (boxed is not null && KotlinResult.IsFailure(boxed.Handle)) + throw KotlinResult.ExtractException(boxed); return unboxFn(boxed); } finally @@ -180,60 +173,4 @@ static void EnsureSuspendedHandle() JNIEnv.DeleteGlobalRef(gref); GC.KeepAlive(inst); } - - static bool IsResultFailure(IntPtr handle) - { - if (handle == IntPtr.Zero) return false; - EnsureResultFailureClass(); - return JNIEnv.IsInstanceOf(handle, s_resultFailureClass); - } - - static void EnsureResultFailureClass() - { - if (s_resultFailureClass != IntPtr.Zero) return; - // Why raw JNI: `kotlin/Result` itself is bound (as - // `Kotlin.Result` in `Xamarin.Kotlin.StdLib.dll`) but its - // members are stripped — the type is a `@JvmInline value class` - // wrapping `Object`, so every accessor emits a mangled JVM - // name (`Result-impl`, `isFailure-impl`, `exceptionOrNull-impl`) - // that the parser drops. Same root cause as - // dotnet/java-interop#1440. The nested `Result$Failure` class - // is intentionally `internal` in Kotlin source and isn't - // surfaced by the binder regardless. We read its private - // `exception` field directly via JNI; once #1440 lands and the - // binder restores `ResultKt.throwOnFailure(Object)`, replace - // this lookup with a call to that bound helper. - // - // JNIEnv.FindClass in Mono.Android returns a stable, globally - // registered class ref — no NewGlobalRef/DeleteLocalRef dance. - // Multi-thread racers all observe the same class ref + field id - // (FindClass / GetFieldID are pure functions of their string - // args), so plain stores are fine — losing the race just - // re-writes identical values. - var cls = JNIEnv.FindClass("kotlin/Result$Failure"); - s_resultFailureExceptionField = JNIEnv.GetFieldID(cls, "exception", "Ljava/lang/Throwable;"); - s_resultFailureClass = cls; - } - - static Exception ExtractFailureException(Java.Lang.Object failure) - { - // s_resultFailureExceptionField was set by EnsureResultFailureClass - // earlier (IsResultFailure path is the only caller). - IntPtr exHandle = JNIEnv.GetObjectField(failure.Handle, s_resultFailureExceptionField); - try - { - // Java.Lang.Throwable : System.Exception, so callers can - // `catch (Exception)` or even pattern-match on the Java type. - var th = global::Java.Lang.Object.GetObject( - exHandle, JniHandleOwnership.TransferLocalRef); - if (th is not null) - return th; - return new InvalidOperationException( - "Kotlin suspend call failed with a null Throwable in Result.Failure"); - } - finally - { - GC.KeepAlive(failure); - } - } } diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index e7712ca6..e2248989 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -162,7 +162,15 @@ protected override void OnCreate(Bundle? savedInstanceState) } }); - string[] tabNames = { "Basics", "Buttons", "Cards", "Drawer", "Selection", "Pickers", "Misc", "App bars", "Lazy", "Carousels", "Pager", "Nav", "State" }; + // Effects tab (issue #57): demonstrate LaunchedEffect, DisposableEffect, + // SideEffect. ticks is incremented from a LaunchedEffect's Task body; + // disposeCount bumps on every DisposableEffect cleanup; effectKey + // restarts everything when bumped. + var ticks = Remember(() => new MutableNumberState(0)); + var effectKey = Remember(() => new MutableNumberState(0)); + var disposeCount = Remember(() => new MutableNumberState(0)); + + string[] tabNames = { "Basics", "Buttons", "Cards", "Drawer", "Selection", "Pickers", "Misc", "App bars", "Lazy", "Carousels", "Pager", "Nav", "State", "Effects" }; // Per-tab content. Only the current tab's column is added to // the screen — keeps the sample short enough to fit on one @@ -1190,6 +1198,68 @@ protected override void OnCreate(Bundle? savedInstanceState) }, }, }, + 13 => new Column + { + // Effects (issue #57) — Compose's three side-effect APIs. + // - SideEffect runs after every successful recomposition. + // - DisposableEffect runs once per (key change | enter + // composition) and calls its cleanup on (key change | + // leave composition). + // - LaunchedEffect launches a C# Task tied to the + // composition's coroutine scope. Cancellation flows + // through the supplied CancellationToken. + Modifier.Companion.Padding(16), + + new Text($"Ticks (LaunchedEffect): {ticks.Value}"), + new Text($"Disposable cleanups: {disposeCount.Value}"), + new Text($"Effect key: {effectKey.Value}"), + new Text("SideEffect: see logcat (filter: ComposeNet.Sample)"), + + new HorizontalDivider { Modifier = Modifier.Companion.Padding(0, 8) }, + + // SideEffect — runs after every successful recomposition + // of this Column. We log to debug rather than write to + // a MutableState (writing snapshot state from a + // SideEffect that the same composition reads would + // create an infinite recomposition loop). + new SideEffect(() => + Android.Util.Log.Debug("ComposeNet.Sample", + $"SideEffect ran (effectKey={effectKey.Value}, ticks={ticks.Value})")), + + // LaunchedEffect — async tick loop scoped to the + // composition. The Task body honors `ct` via + // Task.Delay(ms, ct), so changing effectKey cancels + // and restarts the loop. + new LaunchedEffect(effectKey.Value, async ct => + { + try + { + while (!ct.IsCancellationRequested) + { + await System.Threading.Tasks.Task.Delay(1000, ct); + ticks.Value++; + } + } + catch (System.OperationCanceledException) { } + }), + + // DisposableEffect — fake "register external listener" + // pattern. The cleanup callback bumps a counter so we + // can verify it ran on key change / leaving composition. + new DisposableEffect(effectKey.Value, scope => + { + return () => disposeCount.Value++; + }), + + new Button(onClick: () => effectKey.Value++) + { + new Text("Restart effects (key++)"), + }, + new Button(onClick: () => { ticks.Value = 0; disposeCount.Value = 0; }) + { + new Text("Reset counters"), + }, + }, _ => new Column { // Lazy lists — bound through LazyDslKt / LazyGridDslKt. @@ -1505,6 +1575,11 @@ protected override void OnCreate(Bundle? savedInstanceState) Text = new Text("State"), Icon = new Text("🧠"), }, + new Tab(selected: tab.Value == 13, onClick: () => tab.Value = 13) + { + Text = new Text("Effects"), + Icon = new Text("⚡"), + }, }, tabContent, @@ -1595,4 +1670,4 @@ static string Fmt(long? ms) => ms is long m }; }); } -} \ No newline at end of file +}