diff --git a/.vscode/launch.json b/.vscode/launch.json index 2079c716..42be176a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -48,7 +48,7 @@ "name": "Test PostIt.Android launch (Xamarin.UITest)", "type": "coreclr", "request": "launch", - "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net11.0/PostIt.Tests.dll", + "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", "args": [ ], "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", diff --git a/Makefile b/Makefile index a48b9943..d6d69196 100644 --- a/Makefile +++ b/Makefile @@ -161,9 +161,9 @@ ADB_SERIAL ?= emulator-5554 ANDROID_HOME ?= /opt/android-sdk POSTIT_RID ?= android-x64 EMU_HEADLESS ?= 0 -LOGCAT_LINES ?= 200 +LOGCAT_LINES ?= 600 LOGCAT_FOLLOW ?= 0 -LOGCAT_BOOT_WAIT ?= 30 +LOGCAT_BOOT_WAIT ?= 20 ANDROID_PACKAGE_NAME = fr.pschneider.PostIt POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -249,9 +249,9 @@ qemu-logcat: fi; \ echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ - adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ fi # Clear logcat, launch PostIt.Android, then dump everything that was @@ -270,14 +270,16 @@ qemu-logcat-boot: -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." @sleep $(LOGCAT_BOOT_WAIT) + @echo " Dumping logcat (PostIt PID + system buffer):" @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ if [ -n "$$PID" ]; then \ - echo " (PID $$PID at dump time)"; \ + echo " ✅ (PID $$PID at dump time)"; \ adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ else \ - echo " (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ + echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ + exit 1; \ fi qemu: qemu-run qemu-wait-boot qemu-install diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 3e4a3c70..a4af1eb2 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -8,7 +8,9 @@ 1 1.0 apk - false + false + SdkOnly + partial diff --git a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs index 98859649..e2426457 100644 --- a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs +++ b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Headless.XUnit; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index 2708ba8c..9bc28888 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Avalonia; using Avalonia.Headless.XUnit; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index c11c17b0..433f36c3 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -1,6 +1,6 @@ - net11.0 + net10.0 enable enable false @@ -16,7 +16,9 @@ + + @@ -27,7 +29,5 @@ - - diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 22724f17..10a0c8a5 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; -using Yavsc.Api.Client; +using PostIt.Helpers; namespace PostIt; @@ -28,7 +28,7 @@ public partial class App : Application /// public IServiceProvider? ServiceProvider { get; private set; } - MainWindow window; + public MainWindow? Window { get; private set; } public override void Initialize() { @@ -42,119 +42,58 @@ public partial class App : Application { if (TryHandOffCustomSchemeUrl()) return; - this.ServiceProvider = BuildServices(new ServiceCollection()); + this.ServiceProvider = new ServiceCollection().BuildServices(); var settings = ServiceProvider.GetRequiredService(); - DataTemplates.Clear(); - DataTemplates.Add(new ViewLocator(ServiceProvider)); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = CreateMainWindow(); + ApplyDarkMode(settings); } else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) { singleViewFactoryApplicationLifetime.MainViewFactory = - () => CreateMainWindow(); + () => + { + Window = CreateMainWindow(); + ApplyDarkMode(settings); + return Window; + }; } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) { singleViewPlatform.MainView = CreateMainWindow(); + ApplyDarkMode(settings); } - ApplyDarkMode(settings); base.OnFrameworkInitializationCompleted(); } - internal static IServiceProvider BuildServices(ServiceCollection services) - { - var settings = new Settings(); - settings.Load(); - var tokenStore = new TokenStore(System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "PostIt", "tokens.json")); - - var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); - - // Vues - services.AddTransient(); - // SettingsPage is a singleton: there must be one and only one - // instance of the settings UI for the lifetime of the app. - // This guarantees that (a) the bindings always reflect the - // current in-memory Settings state, (b) the page already has - // its DataContext wired up at composition-root time (see - // below), and (c) PushPageAsync's anti-empilement guard sees - // the same instance across pushes, so a second Settings tap - // is a no-op rather than re-pushing the page. Transient would - // let the user accumulate stale SettingsPage instances on - // the navigation stack, each bound to a fresh - // SettingsViewModel and missing any in-flight edits. - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - // Dialogs (modal-light pages): the ViewLocator resolves - // them when a caller pushes a PostAclDialogViewModel or - // AddCircleMemberDialogViewModel via App.PushPageAsync. - // App.PushPageAsync overwrites the page's DataContext with - // the caller-built VM, so the parameterless ctor is enough - // here — the parametrised ctors stay for direct test wiring. - services.AddTransient(); - services.AddTransient(); - // ViewModels - services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Persistent session banner: one instance for the lifetime of - // the app so the same VM survives page navigation. - var sessionStatus = new SessionStatusViewModel { Api = api }; - sessionStatus.Refresh(); - services.AddSingleton(sessionStatus); - services.AddTransient(); - - return services.BuildServiceProvider(); - } private MainWindow CreateMainWindow() { - window = new MainWindow(); + Window = new MainWindow(); var api = ServiceProvider!.GetRequiredService(); - window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); - var sessionStatus = ServiceProvider!.GetRequiredService(); + Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); + var sessionStatus = ServiceProvider!.GetRequiredService(); sessionStatus.LogoutCompleted += () => { - window.NavRoot.PopToRootAsync(); + Window.NavRoot.PopToRootAsync(); }; sessionStatus.LoginSucceeded += () => { - PushMainPageAsync(); + PushMainPageAsync().Wait(); }; var homeVm = ServiceProvider!.GetRequiredService(); this.PushPageAsync(homeVm).Wait(); - window.SessionBanner.DataContext = sessionStatus; - return window; + Window.SessionBanner.DataContext = sessionStatus; + return Window; } -/// + /// /// Test-only hook: bind a concrete so /// command-driven navigation paths () can /// push onto a real in headless @@ -162,7 +101,7 @@ public partial class App : Application /// internal void AttachMainWindow(MainWindow mainWindow) { - window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); + Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); } private static void ApplyDarkMode(Settings settings) @@ -199,11 +138,11 @@ public partial class App : Application /// (interactive login from the banner). Pulled out as a helper so /// the two callers can't drift apart. /// - public static Task PushMainPageAsync() + public static async Task PushMainPageAsync() { var app = (App)Current!; var mainVm = app.ServiceProvider!.GetRequiredService(); - return app.PushPageAsync(mainVm); + await app.PushPageAsync(mainVm); } private bool TryHandOffCustomSchemeUrl() @@ -238,53 +177,8 @@ public partial class App : Application return true; } - internal void PushPage(ViewModelBase vm) - { - _ = PushPageAsync(vm); - } - - internal async Task PushPageAsync(ViewModelBase vm) - { - if (window is null) - { - throw new InvalidOperationException("MainWindow is not initialized yet."); - } - - var template = DataTemplates.FirstOrDefault(t => t.Match(vm)); - if (template is null) - { - throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); - } - - var view = template.Build(vm); - if (view is null) - { - throw new InvalidOperationException( - $"Template for {vm.GetType().Name} returned ."); - } - - var page = view as Page; - if (page is null) - { - // NavigationPage expects Page instances. Wrap any fallback control - // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. - page = new ContentPage { Content = view }; - } - - page.DataContext = vm; - - // Avoid stacking the same singleton page twice (e.g. SettingsPage). - var stack = window.NavRoot.NavigationStack; - if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) - { - return; - } - - await window.NavRoot.PushAsync(page); - } - internal async Task GoBackAsync() { - await window.NavRoot.PopAsync(); + await Window!.NavRoot.PopAsync(); } } diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs new file mode 100644 index 00000000..9e0382de --- /dev/null +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Api.Client; + +namespace PostIt.Helpers; + +public static class ServiceCollectionHelpers +{ + public static IServiceProvider BuildServices(this ServiceCollection services) + { + var settings = new Settings(); + settings.Load(); + + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + + var api = new YavscApiClient(settings, tokenStore); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); + + // Vues + services.AddTransient(); + // SettingsPage is a singleton: there must be one and only one + // instance of the settings UI for the lifetime of the app. + // This guarantees that (a) the bindings always reflect the + // current in-memory Settings state, (b) the page already has + // its DataContext wired up at composition-root time (see + // below), and (c) PushPageAsync's anti-empilement guard sees + // the same instance across pushes, so a second Settings tap + // is a no-op rather than re-pushing the page. Transient would + // let the user accumulate stale SettingsPage instances on + // the navigation stack, each bound to a fresh + // SettingsViewModel and missing any in-flight edits. + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // Dialogs (modal-light pages): the ViewLocator resolves + // them when a caller pushes a PostAclDialogViewModel or + // AddCircleMemberDialogViewModel via App.PushPageAsync. + // App.PushPageAsync overwrites the page's DataContext with + // the caller-built VM, so the parameterless ctor is enough + // here — the parametrised ctors stay for direct test wiring. + services.AddTransient(); + services.AddTransient(); + // ViewModels + services.AddSingleton(settings); + services.AddSingleton(api); + services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); + services.AddSingleton(userDirectory); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + + return services.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs new file mode 100644 index 00000000..7fc81b3a --- /dev/null +++ b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using PostIt.ViewModels; + +namespace PostIt.Helpers; + +public static class ViewModelBaseHelpers +{ + public static async Task PushPageAsync(this App app, ViewModelBase vm) + { + if (app.Window is null) + { + throw new InvalidOperationException("MainWindow is not initialized yet."); + } + + var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm)); + if (template is null) + { + throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); + } + + var view = template.Build(vm); + if (view is null) + { + throw new InvalidOperationException( + $"Template for {vm.GetType().Name} returned ."); + } + + var page = view as Page; + if (page is null) + { + // NavigationPage expects Page instances. Wrap any fallback control + // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. + page = new ContentPage { Content = view }; + } + + page.DataContext = vm; + + // Avoid stacking the same singleton page twice (e.g. SettingsPage). + var stack = app.Window.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) + { + return; + } + + await app.Window.NavRoot.PushAsync(page); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs index 33a5bd30..9cee3ea8 100644 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -5,6 +5,7 @@ using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index e56c2a73..1aa3eee0 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; +using PostIt.Helpers; namespace PostIt.ViewModels; diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index a1ad48ce..f2496b85 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; namespace PostIt.ViewModels; @@ -139,7 +140,7 @@ public partial class SessionStatusViewModel : ViewModelBase internal async Task OpenSettings() { var app = (App)App.Current!; - await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); + await app.PushPageAsync(app.ServiceProvider!.GetRequiredService()).ConfigureAwait(true); } } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 40e8548b..2115a0cd 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -47,22 +47,7 @@ public partial class Settings : ViewModelBase /// private static Settings? s_current; - /// - /// Wire the canonical Settings instance to a DI container. Called - /// exactly once from App.axaml.cs after the singleton has - /// been registered. Subsequent calls are no-ops: the DI container - /// owns the instance lifetime and we don't want a stray - /// BindToServiceProvider in a test fixture to silently - /// rebind the production instance. - /// - public static void BindToServiceProvider(IServiceProvider services) - { - if (services is null) throw new ArgumentNullException(nameof(services)); - Interlocked.CompareExchange(ref s_current, - services.GetService() ?? throw new InvalidOperationException( - "Settings is not registered in the DI container."), - null); - } + [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -81,7 +66,7 @@ public partial class Settings : ViewModelBase /// setters above all funnel through here, and we flip /// in lock-step. Sub-property mutations /// (e.g. Authentication.Authority) are caught by the - /// subscription wired up in + /// subscription wired up in /// below. disables the flag during bulk /// hydration so the disk load itself does not count as a user /// edit.