diff --git a/src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs b/src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs index d4c3b27f7a..2963c019cc 100644 --- a/src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs +++ b/src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs @@ -6,7 +6,7 @@ using System; namespace Avalonia.Controls.ApplicationLifetimes { /// - /// Contains the arguments for the event. + /// Contains the arguments for the event. /// public class ControlledApplicationLifetimeExitEventArgs : EventArgs { diff --git a/src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs b/src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs index 4c08712707..423832793e 100644 --- a/src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs +++ b/src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs @@ -8,7 +8,7 @@ using System.Linq; namespace Avalonia.Controls.ApplicationLifetimes { /// - /// Contains the arguments for the event. + /// Contains the arguments for the event. /// public class ControlledApplicationLifetimeStartupEventArgs : EventArgs { diff --git a/src/Avalonia.Controls/ShutdownMode.cs b/src/Avalonia.Controls/ShutdownMode.cs index 46e27ff4e1..ee593c28a8 100644 --- a/src/Avalonia.Controls/ShutdownMode.cs +++ b/src/Avalonia.Controls/ShutdownMode.cs @@ -1,10 +1,12 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using Avalonia.Controls.ApplicationLifetimes; + namespace Avalonia.Controls { /// - /// Describes the possible values for . + /// Describes the possible values for . /// public enum ShutdownMode { diff --git a/src/Avalonia.ReactiveUI/AutoSuspendHelper.cs b/src/Avalonia.ReactiveUI/AutoSuspendHelper.cs new file mode 100644 index 0000000000..a4f14a4138 --- /dev/null +++ b/src/Avalonia.ReactiveUI/AutoSuspendHelper.cs @@ -0,0 +1,87 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia; +using Avalonia.VisualTree; +using Avalonia.Controls; +using System.Threading; +using System.Reactive.Disposables; +using System.Reactive.Subjects; +using System.Reactive.Linq; +using System.Reactive; +using ReactiveUI; +using System; +using Avalonia.Controls.ApplicationLifetimes; +using Splat; + +namespace Avalonia.ReactiveUI +{ + /// + /// A ReactiveUI AutoSuspendHelper which initializes suspension hooks for + /// Avalonia applications. Call its constructor in your app's composition root, + /// before calling the RxApp.SuspensionHost.SetupDefaultSuspendResume method. + /// + public sealed class AutoSuspendHelper : IEnableLogger, IDisposable + { + private readonly Subject _shouldPersistState = new Subject(); + private readonly Subject _isLaunchingNew = new Subject(); + + /// + /// Initializes a new instance of the class. + /// + /// Pass in the Application.ApplicationLifetime property. + public AutoSuspendHelper(IApplicationLifetime lifetime) + { + RxApp.SuspensionHost.IsResuming = Observable.Never(); + RxApp.SuspensionHost.IsLaunchingNew = _isLaunchingNew; + + if (lifetime is IControlledApplicationLifetime controlled) + { + this.Log().Debug("Using IControlledApplicationLifetime events to handle app exit."); + controlled.Exit += (sender, args) => OnControlledApplicationLifetimeExit(); + RxApp.SuspensionHost.ShouldPersistState = _shouldPersistState; + } + else if (lifetime != null) + { + var type = lifetime.GetType().FullName; + var message = $"Don't know how to detect app exit event for {type}."; + throw new NotSupportedException(message); + } + else + { + var message = "ApplicationLifetime is null. " + + "Ensure you are initializing AutoSuspendHelper " + + "when Avalonia application initialization is completed."; + throw new ArgumentNullException(message); + } + + var errored = new Subject(); + AppDomain.CurrentDomain.UnhandledException += (o, e) => errored.OnNext(Unit.Default); + RxApp.SuspensionHost.ShouldInvalidateState = errored; + } + + /// + /// Call this method in your App.OnFrameworkInitializationCompleted method. + /// + public void OnFrameworkInitializationCompleted() => _isLaunchingNew.OnNext(Unit.Default); + + /// + /// Disposes internally stored observers. + /// + public void Dispose() + { + _shouldPersistState.Dispose(); + _isLaunchingNew.Dispose(); + } + + private void OnControlledApplicationLifetimeExit() + { + this.Log().Debug("Received IControlledApplicationLifetime exit event."); + var manual = new ManualResetEvent(false); + _shouldPersistState.OnNext(Disposable.Create(() => manual.Set())); + + manual.WaitOne(); + this.Log().Debug("Completed actions on IControlledApplicationLifetime exit event."); + } + } +} diff --git a/tests/Avalonia.ReactiveUI.UnitTests/AutoSuspendHelperTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/AutoSuspendHelperTest.cs new file mode 100644 index 0000000000..876f37cc9e --- /dev/null +++ b/tests/Avalonia.ReactiveUI.UnitTests/AutoSuspendHelperTest.cs @@ -0,0 +1,98 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.ComponentModel; +using System.Threading.Tasks; +using System.Reactive; +using System.Reactive.Subjects; +using System.Reactive.Linq; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Threading; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls; +using Avalonia.Rendering; +using Avalonia.Platform; +using Avalonia.UnitTests; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using Avalonia; +using ReactiveUI; +using DynamicData; +using Xunit; +using Splat; + +namespace Avalonia.ReactiveUI.UnitTests +{ + public class AutoSuspendHelperTest + { + [DataContract] + public class AppState + { + [DataMember] + public string Example { get; set; } + } + + public class ExoticApplicationLifetimeWithoutLifecycleEvents : IDisposable, IApplicationLifetime + { + public void Dispose() { } + } + + [Fact] + public void AutoSuspendHelper_Should_Immediately_Fire_IsLaunchingNew() + { + using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) + using (var lifetime = new ClassicDesktopStyleApplicationLifetime(Application.Current)) + { + var isLaunchingReceived = false; + var application = AvaloniaLocator.Current.GetService(); + application.ApplicationLifetime = lifetime; + + // Initialize ReactiveUI Suspension as in real-world scenario. + var suspension = new AutoSuspendHelper(application.ApplicationLifetime); + RxApp.SuspensionHost.IsLaunchingNew.Subscribe(_ => isLaunchingReceived = true); + suspension.OnFrameworkInitializationCompleted(); + + Assert.True(isLaunchingReceived); + } + } + + [Fact] + public void ShouldPersistState_Should_Fire_On_App_Exit_When_SuspensionDriver_Is_Initialized() + { + using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) + using (var lifetime = new ClassicDesktopStyleApplicationLifetime(Application.Current)) + { + var shouldPersistReceived = false; + var application = AvaloniaLocator.Current.GetService(); + application.ApplicationLifetime = lifetime; + + // Initialize ReactiveUI Suspension as in real-world scenario. + var suspension = new AutoSuspendHelper(application.ApplicationLifetime); + RxApp.SuspensionHost.CreateNewAppState = () => new AppState { Example = "Foo" }; + RxApp.SuspensionHost.ShouldPersistState.Subscribe(_ => shouldPersistReceived = true); + RxApp.SuspensionHost.SetupDefaultSuspendResume(new DummySuspensionDriver()); + suspension.OnFrameworkInitializationCompleted(); + + lifetime.Shutdown(); + Assert.True(shouldPersistReceived); + Assert.Equal("Foo", RxApp.SuspensionHost.GetAppState().Example); + } + } + + [Fact] + public void AutoSuspendHelper_Should_Throw_For_Not_Supported_Lifetimes() + { + using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) + using (var lifetime = new ExoticApplicationLifetimeWithoutLifecycleEvents()) + { + var application = AvaloniaLocator.Current.GetService(); + application.ApplicationLifetime = lifetime; + Assert.Throws(() => new AutoSuspendHelper(application.ApplicationLifetime)); + } + } + } +} \ No newline at end of file diff --git a/tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs index 2c81e8fea3..1d85312b1a 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs +++ b/tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs @@ -171,7 +171,7 @@ namespace Avalonia.ReactiveUI.UnitTests [Fact] public void Activation_For_View_Fetcher_Should_Support_Windows() { - using (var application = UnitTestApplication.Start(TestServices.MockWindowingPlatform)) + using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) { var window = new TestWindowWithWhenActivated(); Assert.False(window.Active); @@ -187,7 +187,7 @@ namespace Avalonia.ReactiveUI.UnitTests [Fact] public void Activatable_Window_View_Model_Is_Activated_And_Deactivated() { - using (var application = UnitTestApplication.Start(TestServices.MockWindowingPlatform)) + using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) { var viewModel = new ActivatableViewModel(); var window = new ActivatableWindow { ViewModel = viewModel };