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.