diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 876861ce68..25eb7baa61 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -23,6 +23,9 @@ jobs: installWorkloads: true installWindowsAppRuntime: true # Test only the test projects: the solution also builds the documentation example apps, which are not tests. - testProjects: tests/**/*.csproj + # The device test app needs an emulator or simulator; device-tests.yml runs it. + testProjects: | + tests/**/*.csproj + !tests/ReactiveUI.Device.Tests/** secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/device-tests.yml b/.github/workflows/device-tests.yml new file mode 100644 index 0000000000..7bf6d62aa2 --- /dev/null +++ b/.github/workflows/device-tests.yml @@ -0,0 +1,19 @@ +name: Device Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + device-tests: + uses: reactiveui/actions-common/.github/workflows/workflow-common-device-tests.yml@main + with: + projects: src/tests/ReactiveUI.Device.Tests/ReactiveUI.Device.Tests.csproj + configuration: Release + solutionFile: reactiveui.slnx diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 6285515d1b..3050d0e526 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -17,7 +17,10 @@ jobs: installWorkloads: true installWindowsAppRuntime: true # Test only the test projects: the solution also builds the documentation example apps, which are not tests. - testProjects: tests/**/*.csproj + # The device test app needs an emulator or simulator; device-tests.yml runs it. + testProjects: | + tests/**/*.csproj + !tests/ReactiveUI.Device.Tests/** sonarProjectKey: reactiveui_ReactiveUI sonarOrganization: reactiveui sonarExclusions: '**/tests/**,**/integrationtests/**,**/benchmarks/**,**/examples/**,**/TestResults/**' diff --git a/src/reactiveui.slnx b/src/reactiveui.slnx index 387ef9be19..65518adee4 100644 --- a/src/reactiveui.slnx +++ b/src/reactiveui.slnx @@ -58,6 +58,7 @@ + @@ -68,6 +69,7 @@ + diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ActivatingActivity.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ActivatingActivity.cs new file mode 100644 index 0000000000..68c1749c5e --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ActivatingActivity.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Widget; + +namespace ReactiveUI.Device.Tests; + +/// A that counts its activations and wires its layout by name. +[Activity(Exported = false)] +public class ActivatingActivity : ReactiveActivity +{ + /// The number of times the activity has been activated. + private int _activations; + + /// The number of times the activity has been deactivated. + private int _deactivations; + + /// Initializes a new instance of the class. + public ActivatingActivity() => + this.WhenActivated(disposables => + { + _ = Interlocked.Increment(ref _activations); + disposables(new ActionDisposable(() => Interlocked.Increment(ref _deactivations))); + }); + + /// Gets the most recently created instance, so a test can reach the instance Android creates when it recreates one. + public static ActivatingActivity? LastCreated { get; private set; } + + /// Gets the number of times the activity has been activated. + public int Activations => Volatile.Read(ref _activations); + + /// Gets a value indicating whether Android passed saved state to . + public bool CreatedFromSavedState { get; private set; } + + /// Gets the number of times the activity has been deactivated. + public int Deactivations => Volatile.Read(ref _deactivations); + + /// Gets or sets the title control, wired by name. + public TextView? TitleText { get; set; } + + /// Gets or sets the save button, wired by name. + public Button? SaveButton { get; set; } + + /// Gets the error wiring the controls threw, if it threw. + public Exception? WireUpError { get; private set; } + + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + CreatedFromSavedState = savedInstanceState is not null; + LastCreated = this; + SetContentView(Resource.Layout.wireup_layout); + + try + { + this.WireUpControls(); + } + catch (MissingFieldException ex) + { + WireUpError = ex; + } + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/CompatHostActivity.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/CompatHostActivity.cs new file mode 100644 index 0000000000..6e5bde5115 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/CompatHostActivity.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Views; +using Android.Widget; +using ReactiveUI.AndroidX; + +namespace ReactiveUI.Device.Tests; + +/// A that hosts a . +[Activity(Exported = false)] +public class CompatHostActivity : ReactiveAppCompatActivity +{ + /// The number of times the activity has been activated. + private int _activations; + + /// Initializes a new instance of the class. + public CompatHostActivity() => + this.WhenActivated((Action _) => Interlocked.Increment(ref _activations)); + + /// Gets the number of times the activity has been activated. + public int Activations => Volatile.Read(ref _activations); + + /// Gets the hosted fragment. + public WiredFragment Fragment { get; } = new(); + + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var container = new FrameLayout(this) { Id = View.GenerateViewId() }; + SetContentView(container); + SupportFragmentManager!.BeginTransaction()!.Add(container.Id, Fragment)!.CommitNow(); + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoBinder.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoBinder.cs new file mode 100644 index 0000000000..3101c276c6 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoBinder.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.OS; + +namespace ReactiveUI.Device.Tests; + +/// The binder returns; a local client receives this same instance. +public class EchoBinder : Binder +{ + /// Returns the text it was given, so a test can call through the bound service. + /// The text to echo. + /// The same text. + public string Echo(string text) => text; +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoService.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoService.cs new file mode 100644 index 0000000000..361704c054 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoService.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Content; +using Android.OS; + +namespace ReactiveUI.Device.Tests; + +/// A local bound service the service-binding tests connect to. +[Service(Exported = false)] +public class EchoService : Service +{ + /// The binder handed to every client. + private readonly EchoBinder _binder = new(); + + /// + public override IBinder? OnBind(Intent? intent) => _binder; +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/HostActivity.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/HostActivity.cs new file mode 100644 index 0000000000..ec873e4e1d --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/HostActivity.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Widget; + +namespace ReactiveUI.Device.Tests; + +/// A plain activity whose content view the tests add views to, so the views attach to a real window. +[Activity(Exported = false)] +public class HostActivity : Activity +{ + /// Gets the container the tests add views to. + public FrameLayout Container { get; private set; } = null!; + + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + Container = new(this); + SetContentView(Container); + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ResultSourceActivity.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ResultSourceActivity.cs new file mode 100644 index 0000000000..7554fa482b --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ResultSourceActivity.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Content; +using AndroidResult = Android.App.Result; + +namespace ReactiveUI.Device.Tests; + +/// An activity that returns a result to its caller as soon as it is created. +[Activity(Exported = false)] +public class ResultSourceActivity : Activity +{ + /// The intent extra key carrying the answer. + internal const string AnswerKey = "answer"; + + /// The answer the activity returns. + internal const int Answer = 42; + + /// + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var data = new Intent(); + _ = data.PutExtra(AnswerKey, Answer); + SetResult(AndroidResult.Ok, data); + Finish(); + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/WiredFragment.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/WiredFragment.cs new file mode 100644 index 0000000000..0d61fad5f4 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/WiredFragment.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Views; +using Android.Widget; +using ReactiveUI.AndroidX; + +namespace ReactiveUI.Device.Tests; + +/// An AndroidX that wires its layout by name and counts activations. +public class WiredFragment : ReactiveUI.AndroidX.ReactiveFragment +{ + /// The number of times the fragment has been activated. + private int _activations; + + /// The number of times the fragment has been deactivated. + private int _deactivations; + + /// Initializes a new instance of the class. + public WiredFragment() => + this.WhenActivated(disposables => + { + _ = Interlocked.Increment(ref _activations); + disposables(new ActionDisposable(() => Interlocked.Increment(ref _deactivations))); + }); + + /// Gets the number of times the fragment has been activated. + public int Activations => Volatile.Read(ref _activations); + + /// Gets the number of times the fragment has been deactivated. + public int Deactivations => Volatile.Read(ref _deactivations); + + /// Gets or sets the title control, wired by name. + public TextView? TitleText { get; set; } + + /// Gets or sets the renamed input, wired through its resource-name override. + [WireUpResource("renamed_input")] + public EditText? Input { get; set; } + + /// + public override View? OnCreateView(LayoutInflater? inflater, ViewGroup? container, Bundle? savedInstanceState) + { + if (inflater?.Inflate(Resource.Layout.wireup_layout, container, false) is not { } view) + { + return null; + } + + this.WireUpControls(view); + return view; + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/AndroidManifest.xml b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000000..ac4e760b1d --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/ActivityLauncher.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/ActivityLauncher.cs new file mode 100644 index 0000000000..dc7459d9a5 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/ActivityLauncher.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.Content; + +namespace ReactiveUI.Device.Tests; + +/// Starts and finishes real activities through the test instrumentation. +internal static class ActivityLauncher +{ + /// Gets the application context of the app under test. + internal static Context TargetContext => TestInstrumentation.Current.TargetContext!; + + /// Starts and waits until it is resumed. + /// The activity type, declared in the manifest by its [Activity] attribute. + /// The resumed activity. + internal static Task StartAsync() + where TActivity : Activity => + Task.Run(static () => + { + var intent = new Intent(TargetContext, typeof(TActivity)); + _ = intent.AddFlags(ActivityFlags.NewTask); + + // StartActivitySync blocks until the activity is resumed and must not run on the main thread. + return (TActivity)TestInstrumentation.Current.StartActivitySync(intent)!; + }); + + /// Finishes and waits for the main thread to go idle. + /// The activity to finish. + /// A task that completes once the finish has been processed. + internal static async Task FinishAsync(Activity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + await MainThread.RunAsync(activity.Finish).ConfigureAwait(false); + await Task.Run(TestInstrumentation.Current.WaitForIdleSync).ConfigureAwait(false); + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/AndroidAssemblyHooks.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/AndroidAssemblyHooks.cs new file mode 100644 index 0000000000..f274b80799 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/AndroidAssemblyHooks.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Builder; +using Splat; + +namespace ReactiveUI.Device.Tests; + +/// Starts ReactiveUI once for the whole run, the way an AndroidX app starts it. +public static class AndroidAssemblyHooks +{ + /// Gets the suspension helper the app registers, as an app does in its Application.OnCreate. + internal static AutoSuspendHelper SuspendHelper { get; private set; } = null!; + + /// Builds ReactiveUI with the AndroidX platform module on the main thread and registers the suspension helper. + /// A task that completes once ReactiveUI is built. + /// + /// The mode detector reports a normal app rather than a unit test runner, so the platform registrations take the + /// same path they take in a shipped app. + /// + [Before(Assembly)] + public static Task StartReactiveUI() + { + ModeDetector.OverrideModeDetector(AppModeDetector.Instance); + return MainThread.RunAsync(static () => + { + _ = RxAppBuilder.CreateReactiveUIBuilder().WithAndroidX().BuildApp(); + SuspendHelper = new((Application)ActivityLauncher.TargetContext.ApplicationContext!); + }); + } + + /// Disposes the suspension helper after the last test. + [After(Assembly)] + public static void StopReactiveUI() => SuspendHelper?.Dispose(); + + /// A mode detector that reports a normal app. + private sealed class AppModeDetector : IModeDetector + { + /// Gets the shared instance. + public static AppModeDetector Instance { get; } = new(); + + /// + public bool? InUnitTestRunner() => false; + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/MainThread.cs b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/MainThread.cs new file mode 100644 index 0000000000..8de5d5779d --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/MainThread.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.OS; + +namespace ReactiveUI.Device.Tests; + +/// Runs test code on the Android main (UI) thread, where views must be created and touched. +internal static class MainThread +{ + /// The handler bound to the main looper. + private static readonly Handler MainHandler = new(Looper.MainLooper!); + + /// Gets a value indicating whether the calling thread is the main thread. + internal static bool IsCurrent => Looper.MainLooper!.IsCurrentThread; + + /// Runs on the main thread and waits for it. + /// The work to run. + /// A task that completes when the work has run. + internal static Task RunAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + return RunAsync(() => + { + action(); + return null; + }); + } + + /// Runs on the main thread and returns its result. + /// The result type. + /// The work to run. + /// A task carrying the result. + internal static Task RunAsync(Func func) + { + ArgumentNullException.ThrowIfNull(func); + + if (IsCurrent) + { + return Task.FromResult(func()); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!MainHandler.Post(() => + { + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + })) + { + completion.SetException(new InvalidOperationException("The main looper rejected the work.")); + } + + return completion.Task; + } +} diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/item_layout.xml b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/item_layout.xml new file mode 100644 index 0000000000..f9a1a86b2d --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/item_layout.xml @@ -0,0 +1,8 @@ + + + diff --git a/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/wireup_layout.xml b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/wireup_layout.xml new file mode 100644 index 0000000000..9ce9a82949 --- /dev/null +++ b/src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/wireup_layout.xml @@ -0,0 +1,25 @@ + + + + + + +