From c0829e8641d39f141d4bfd9fc74e329ad2b676cf Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:20:51 +1000 Subject: [PATCH 1/7] test: add Android and iOS device tests run through dotnet test --device - Replace the stale ReactiveUI.NonParallel.Mobile.Tests (xUnit runners, missing project reference) with ReactiveUI.Device.Tests - Run TUnit in-process on the device: an Android instrumentation relays results to `dotnet test --device`, an iOS app delegate writes a TRX report and exit code - Cover HandlerSequencer, WithAndroidX registrations, PlatformOperations, activation, ControlFetcherMixins, LayoutViewHost, ReactiveViewHost, AutoSuspendHelper, BundleSuspensionDriver, SharedPreferences, service binding and the AndroidX adapters on a real emulator - Cover NSRunloopSequencer, UIKit registrations, ViewModelViewHost, RoutedViewHost, ReactiveViewController, AppSupportJsonSuspensionDriver and IndexNormalizer on the iOS simulator - Work around TUnit subscribing to Console.CancelKeyPress, which throws on Android and iOS - Add the project to the solution and exclude it from the host test runs in ci-build.yml and sonarcloud.yml - Add device-tests.yml, which calls the actions-common device-tests workflow --- .github/workflows/ci-build.yml | 5 +- .github/workflows/device-tests.yml | 20 ++ .github/workflows/sonarcloud.yml | 5 +- src/reactiveui.slnx | 2 + .../Android/Activities/ActivatingActivity.cs | 66 +++++++ .../Android/Activities/CompatHostActivity.cs | 38 ++++ .../Android/Activities/EchoBinder.cs | 17 ++ .../Android/Activities/EchoService.cs | 20 ++ .../Android/Activities/HostActivity.cs | 24 +++ .../Activities/ResultSourceActivity.cs | 31 +++ .../Android/Activities/WiredFragment.cs | 53 +++++ .../Platforms/Android/AndroidManifest.xml | 4 + .../Infrastructure/ActivityLauncher.cs | 40 ++++ .../Infrastructure/AndroidAssemblyHooks.cs | 47 +++++ .../Android/Infrastructure/MainThread.cs | 63 ++++++ .../Android/Resources/layout/item_layout.xml | 8 + .../Resources/layout/wireup_layout.xml | 25 +++ .../Android/Resources/values/strings.xml | 4 + .../Platforms/Android/TestInstrumentation.cs | 187 ++++++++++++++++++ .../Android/Tests/ActivationTests.cs | 111 +++++++++++ .../Platforms/Android/Tests/AdapterTests.cs | 140 +++++++++++++ .../Android/Tests/AndroidExtensionsTests.cs | 110 +++++++++++ .../Android/Tests/AndroidRegistrationTests.cs | 58 ++++++ .../Tests/ControlFetcherMixinsTests.cs | 184 +++++++++++++++++ .../Android/Tests/HandlerSequencerTests.cs | 95 +++++++++ .../Android/Tests/LayoutViewHostTests.cs | 97 +++++++++ .../Android/Tests/PlatformOperationsTests.cs | 36 ++++ .../Android/Tests/ReactiveViewHostTests.cs | 115 +++++++++++ .../Android/Tests/SuspensionTests.cs | 142 +++++++++++++ .../Platforms/Android/Views/AutoWiredHost.cs | 38 ++++ .../Platforms/Android/Views/BoundHost.cs | 36 ++++ .../Platforms/Android/Views/OptOutLayout.cs | 41 ++++ .../Android/Views/RecordingDataObserver.cs | 33 ++++ .../Android/Views/TestRecyclerAdapter.cs | 24 +++ .../Platforms/Android/Views/TestViewHolder.cs | 35 ++++ .../Platforms/Android/Views/TestViewHost.cs | 53 +++++ .../Platforms/Android/Views/WiredLayout.cs | 36 ++++ .../Platforms/iOS/AppDelegate.cs | 109 ++++++++++ .../iOS/Infrastructure/AppleAssemblyHooks.cs | 36 ++++ .../iOS/Infrastructure/MainThread.cs | 58 ++++++ .../iOS/Infrastructure/UIKitHarness.cs | 47 +++++ .../Platforms/iOS/Program.cs | 16 ++ .../AppSupportJsonSuspensionDriverTests.cs | 60 ++++++ .../iOS/Tests/AppleRegistrationTests.cs | 91 +++++++++ .../iOS/Tests/IndexNormalizerTests.cs | 75 +++++++ .../iOS/Tests/ReactiveViewControllerTests.cs | 41 ++++ .../iOS/Tests/RoutedViewHostTests.cs | 103 ++++++++++ .../iOS/Tests/ViewModelViewHostTests.cs | 131 ++++++++++++ .../iOS/Views/RoutableViewController.cs | 9 + .../Platforms/iOS/Views/TestViewController.cs | 30 +++ src/tests/ReactiveUI.Device.Tests/README.md | 59 ++++++ .../ReactiveUI.Device.Tests.csproj | 90 +++++++++ .../Runner/ConsoleCancelKeyShim.cs | 51 +++++ .../Runner/DeviceTestOutcome.cs | 22 +++ .../Runner/DeviceTestResult.cs | 21 ++ .../Runner/DeviceTestSession.cs | 156 +++++++++++++++ .../Runner/IDeviceTestReporter.cs | 18 ++ .../Shared/AssemblyInfo.cs | 7 + .../Shared/DelegateViewLocator.cs | 34 ++++ .../Shared/Eventually.cs | 36 ++++ .../Shared/SignalAwaiterExtensions.cs | 73 +++++++ .../Shared/SuspensionState.cs | 11 ++ .../Shared/SuspensionStateJsonContext.cs | 12 ++ .../Shared/TestRoutableViewModel.cs | 18 ++ .../Shared/TestScreen.cs | 13 ++ .../Shared/TestViewModel.cs | 27 +++ .../Platforms/android/MainActivity.cs | 47 ----- .../Platforms/android/MainActivity.cs.txt | 43 ---- .../android/PropertyBindingTestViews.cs | 112 ----------- .../Platforms/cocoa/AppDelegate.cs | 77 -------- .../Platforms/cocoa/AppDelegate.cs.txt | 51 ----- .../Platforms/cocoa/Entitlements.plist | 6 - .../Platforms/cocoa/IndexNormalizerTest.cs | 120 ----------- .../Platforms/cocoa/Info.plist | 32 --- .../Platforms/cocoa/Main.cs | 30 --- .../cocoa/PropertyBindingTestViews.cs | 116 ----------- .../Platforms/cocoa/UnitTestAppDelegate.cs | 67 ------- .../README.md | 28 --- ...ReactiveUI.NonParallel.Mobile.Tests.csproj | 45 ----- 79 files changed, 3495 insertions(+), 776 deletions(-) create mode 100644 .github/workflows/device-tests.yml create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ActivatingActivity.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/CompatHostActivity.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoBinder.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/EchoService.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/HostActivity.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/ResultSourceActivity.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Activities/WiredFragment.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/AndroidManifest.xml create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/ActivityLauncher.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/AndroidAssemblyHooks.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Infrastructure/MainThread.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/item_layout.xml create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/layout/wireup_layout.xml create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Resources/values/strings.xml create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/TestInstrumentation.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/ActivationTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/AdapterTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/AndroidExtensionsTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/AndroidRegistrationTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/ControlFetcherMixinsTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/HandlerSequencerTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/LayoutViewHostTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/PlatformOperationsTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/ReactiveViewHostTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Tests/SuspensionTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/AutoWiredHost.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/BoundHost.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/OptOutLayout.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/RecordingDataObserver.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/TestRecyclerAdapter.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/TestViewHolder.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/TestViewHost.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/Android/Views/WiredLayout.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/AppDelegate.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Infrastructure/AppleAssemblyHooks.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Infrastructure/MainThread.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Infrastructure/UIKitHarness.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Program.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/AppSupportJsonSuspensionDriverTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/AppleRegistrationTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/IndexNormalizerTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/ReactiveViewControllerTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/RoutedViewHostTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Tests/ViewModelViewHostTests.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Views/RoutableViewController.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Platforms/iOS/Views/TestViewController.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/README.md create mode 100644 src/tests/ReactiveUI.Device.Tests/ReactiveUI.Device.Tests.csproj create mode 100644 src/tests/ReactiveUI.Device.Tests/Runner/ConsoleCancelKeyShim.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Runner/DeviceTestOutcome.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Runner/DeviceTestResult.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Runner/DeviceTestSession.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Runner/IDeviceTestReporter.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/AssemblyInfo.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/DelegateViewLocator.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/Eventually.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/SignalAwaiterExtensions.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/SuspensionState.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/SuspensionStateJsonContext.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/TestRoutableViewModel.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/TestScreen.cs create mode 100644 src/tests/ReactiveUI.Device.Tests/Shared/TestViewModel.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/android/MainActivity.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/android/MainActivity.cs.txt delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/android/PropertyBindingTestViews.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/AppDelegate.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/AppDelegate.cs.txt delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/Entitlements.plist delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/IndexNormalizerTest.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/Info.plist delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/Main.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/PropertyBindingTestViews.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/Platforms/cocoa/UnitTestAppDelegate.cs delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/README.md delete mode 100644 src/tests/ReactiveUI.NonParallel.Mobile.Tests/ReactiveUI.NonParallel.Mobile.Tests.csproj 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..22f144f39d --- /dev/null +++ b/.github/workflows/device-tests.yml @@ -0,0 +1,20 @@ +name: Device Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + device-tests: + # TODO: switch to @main once reactiveui/actions-common adds workflow-common-device-tests.yml there. + uses: reactiveui/actions-common/.github/workflows/workflow-common-device-tests.yml@ci/device-tests + 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 @@ + + + + + + +