diff --git a/Makefile b/Makefile index b7d49840..a48b9943 100644 --- a/Makefile +++ b/Makefile @@ -165,9 +165,10 @@ LOGCAT_LINES ?= 200 LOGCAT_FOLLOW ?= 0 LOGCAT_BOOT_WAIT ?= 30 +ANDROID_PACKAGE_NAME = fr.pschneider.PostIt POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) -POSTIT_APK := $(POSTIT_APK_DIR)/fr.pschneider.PostIt-Signed.apk +POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk qemu-run: @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." @@ -227,12 +228,12 @@ qemu-install: qemu-build adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r qemu-uninstall: - adb -s $(ADB_SERIAL) uninstall fr.pschneider.PostIt + adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) # Dump recent logcat output for the running PostIt.Android process. # By default, prints the last $(LOGCAT_LINES) lines (one-shot, with # `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. -# Filtering is by PID (pidof fr.pschneider.PostIt), not by tag, +# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, # because Mono/Xamarin can emit logs under several tags # (mono, PostIt.Android, Avalonia.Android) and tag-based filtering # would miss the ones not matching. PID-based filtering is exact. @@ -240,17 +241,17 @@ qemu-uninstall: # silently with no output; that is the expected behaviour for # "no logs yet". qemu-logcat: - @PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 2>/dev/null | tr -d '\r\n'); \ + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ if [ -z "$$PID" ]; then \ - echo " fr.pschneider.PostIt is not running on $(ADB_SERIAL)."; \ - echo " Start the app first (am start -n fr.pschneider.PostIt/PostIt.Android.PostItMainActivity)"; \ + echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ exit 1; \ 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 fr.pschneider.PostIt:F; \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID fr.pschneider.PostIt:F; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ fi # Clear logcat, launch PostIt.Android, then dump everything that was @@ -264,13 +265,13 @@ LOGCAT_BOOT_WAIT ?= 15 qemu-logcat-boot: @echo " Clearing logcat buffer..." adb -s $(ADB_SERIAL) logcat -c - @echo " Launching fr.pschneider.PostIt..." + @echo " Launching $(ANDROID_PACKAGE_NAME)..." adb -s $(ADB_SERIAL) shell am start \ - -n fr.pschneider.PostIt/PostIt.Android.PostItMainActivity + -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 fr.pschneider.PostIt 2>/dev/null | tr -d '\r\n'); \ + @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)"; \ adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 2c57fb5f..69e8a896 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,9 +1,13 @@ - - + + + true + + + @@ -11,28 +15,23 @@ + + + + + + + + + + + - - - - - - - - - - - - - - - - - + diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index fb6b08d3..f5a7908d 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -2,6 +2,12 @@ using Android.Runtime; using Avalonia; using Avalonia.Android; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Avalonia.Controls; +using Avalonia.Styling; +using Yavsc.Api.Client; namespace PostIt.Android { diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index e85b2dee..62c29e10 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -1,11 +1,8 @@ -using Android.App; -using Android.Content.PM; +using Android.App; using Android.Content; +using Android.Content.PM; +using Avalonia; using Avalonia.Android; -using AndroidX.Emoji2.Text; -using AndroidX.Core.Provider; -using Android; -using Android.Graphics; namespace PostIt.Android; @@ -15,11 +12,10 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - LaunchMode = LaunchMode.SingleTask, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { - /// + /// /// Strongly-typed handle to the current MainActivity instance, set in /// and consumed by platform services such as /// which need to launch @@ -33,8 +29,7 @@ public class MainActivity : AvaloniaMainActivity PlatformBootstrap.EnsureInitialized(); Current = this; } - - /// + /// /// Receives the deep-link Intent fired by the system browser after the /// user completes the OIDC login on https://yavsc.pschneider.fr. The /// Intent URI has the shape android://postit-signin?code=...&state=.... diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index c7202c78..3e4a3c70 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -10,19 +10,22 @@ apk false + Resources\drawable\Icon.png - - - + - - + + + + + + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 2472d06d..91b61d05 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,7 +1,7 @@ - + - + - + + + - + - - + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 4161635d..22724f17 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,16 +1,16 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; +using Microsoft.Extensions.DependencyInjection; using PostIt.Services; -using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; +using Yavsc.Api.Client; namespace PostIt; @@ -28,9 +28,7 @@ public partial class App : Application /// public IServiceProvider? ServiceProvider { get; private set; } - public App() - { - } + MainWindow window; public override void Initialize() { @@ -42,101 +40,31 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - // Belt-and-braces 2nd-instance guard. The primary check now - // lives in PostIt.Desktop.Program.Main and exits before - // Avalonia boots — preventing a flash of the MainWindow on - // every postit://callback launch. This block is kept for any - // entry point that bypasses Program.Main (PostIt.Browser, - // PostIt.Android's process lifecycle, ad-hoc tests that build - // App directly) and as defence-in-depth in case the Desktop - // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; this.ServiceProvider = BuildServices(new ServiceCollection()); - AttachServiceProvider(ServiceProvider); var settings = ServiceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); - // Wire the Settings singleton onto the SettingsPage singleton - // once, at composition time. The page is registered as a - // singleton (see above) precisely so this binding is stable - // for the lifetime of the app: every push to / pop from the - // navigation stack finds the same ContentPage with the same - // DataContext, and the TwoWay bindings inside the page keep - // mutating the same in-memory Settings instance that the rest - // of the app reads (OidcClientOptions construction, etc.). - ServiceProvider.GetRequiredService().DataContext = settings; - - // Settings.DarkMode was previously a dead field: it round- - // tripped through the settings file and the SettingsPage - // CheckBox, but no consumer ever read it. Wire it here to - // Application.RequestedThemeVariant so the toggle takes - // effect immediately, and seed the initial theme from the - // value Load() just populated (so a dark-mode user lands on - // a dark window on first launch, not on a default-light - // window that flips after the user touches the toggle). - ApplyDarkMode(settings); - settings.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(Settings.DarkMode)) - { - ApplyDarkMode(settings); - } - }; - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = CreateMainWindow(); } else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) { - singleViewFactoryApplicationLifetime.MainViewFactory = () => CreateMainWindow(); + singleViewFactoryApplicationLifetime.MainViewFactory = + () => CreateMainWindow(); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) { singleViewPlatform.MainView = CreateMainWindow(); } + ApplyDarkMode(settings); base.OnFrameworkInitializationCompleted(); } - MainWindow window; - private MainWindow CreateMainWindow() - { - window = new MainWindow(); - var api = ServiceProvider!.GetRequiredService(); - window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); - var sessionStatus = ServiceProvider!.GetRequiredService(); - sessionStatus.LogoutCompleted += () => - { - window.NavRoot.PopToRootAsync(); - }; - - sessionStatus.LoginSucceeded += () => - { - PushMainPageAsync(); - }; - - var homeVm = ServiceProvider!.GetRequiredService(); - - this.PushPageAsync(homeVm).Wait(); - window.SessionBanner.DataContext = sessionStatus; - return window; - } - - /// - /// Build the DI container the app uses. Pulled out of - /// so headless - /// tests can construct the same container at TestApp boot - /// without going through the full Avalonia desktop lifetime - /// (which never runs in a unit test). The container returned is - /// the exact one production uses — no test-only fakes, no - /// trimmed service list — so a test that exercises a VM, page, - /// or service resolves through the same wiring the real app - /// does, and a green test is a green contract for prod. - /// internal static IServiceProvider BuildServices(ServiceCollection services) { var settings = new Settings(); @@ -182,14 +110,13 @@ public partial class App : Application // ViewModels services.AddSingleton(settings); services.AddSingleton(api); - 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(); services.AddTransient(); @@ -204,21 +131,30 @@ public partial class App : Application return services.BuildServiceProvider(); } - /// - /// Attach a pre-built DI container to this - /// instance. Used by headless tests after - /// ; in production this happens - /// implicitly via . - /// Idempotent w.r.t. : - /// re-binding from a second App boot is a no-op. - /// - internal void AttachServiceProvider(IServiceProvider sp) + private MainWindow CreateMainWindow() { - ServiceProvider = sp; - Settings.BindToServiceProvider(sp); + window = new MainWindow(); + var api = ServiceProvider!.GetRequiredService(); + window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); + var sessionStatus = ServiceProvider!.GetRequiredService(); + sessionStatus.LogoutCompleted += () => + { + window.NavRoot.PopToRootAsync(); + }; + + sessionStatus.LoginSucceeded += () => + { + PushMainPageAsync(); + }; + + var homeVm = ServiceProvider!.GetRequiredService(); + + this.PushPageAsync(homeVm).Wait(); + window.SessionBanner.DataContext = sessionStatus; + return window; } - /// +/// /// Test-only hook: bind a concrete so /// command-driven navigation paths () can /// push onto a real in headless @@ -266,7 +202,7 @@ public partial class App : Application public static Task PushMainPageAsync() { var app = (App)Current!; - var mainVm = app.ServiceProvider!.GetRequiredService(); + var mainVm = app.ServiceProvider!.GetRequiredService(); return app.PushPageAsync(mainVm); } diff --git a/src/PostIt/PostIt/Assets/avalonia-logo.ico b/src/PostIt/PostIt/Assets/avalonia-logo.ico new file mode 100644 index 00000000..f7da8bb5 Binary files /dev/null and b/src/PostIt/PostIt/Assets/avalonia-logo.ico differ diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 1b48c21f..70bd72ca 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -1,32 +1,14 @@ - + net10.0 enable latest - true - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 - 1.1.0-beta.1 + - - - - - - - None - All - - - - - - - + PreserveNewest @@ -38,6 +20,21 @@ - + + + + + None + All + + + + + + + + + + diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index fd92c802..03ab98a1 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; using Avalonia.Controls.Templates; using Microsoft.Extensions.DependencyInjection; @@ -10,17 +11,19 @@ namespace PostIt; /// /// Given a view model, returns the corresponding view if possible. /// - +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] public class ViewLocator : IDataTemplate { - private readonly IServiceProvider _services; + private readonly IServiceProvider _services; public ViewLocator(IServiceProvider services) { _services = services; } - public Control Build(object? data) + public Control Build(object? data) { try { @@ -32,11 +35,12 @@ public class ViewLocator : IDataTemplate } } + private Control BuildCore(object? data) { return data switch { - MainPageViewModel => _services.GetRequiredService(), + MainViewModel => _services.GetRequiredService(), Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), @@ -48,5 +52,5 @@ public class ViewLocator : IDataTemplate }; } - public bool Match(object? data) => data is ViewModelBase; + public bool Match(object? data) => data is ViewModelBase; } diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs similarity index 98% rename from src/PostIt/PostIt/ViewModels/MainPageViewModel.cs rename to src/PostIt/PostIt/ViewModels/MainViewModel.cs index ae5b0f63..e56c2a73 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -11,7 +11,7 @@ using Yavsc.Api.Client; namespace PostIt.ViewModels; -public partial class MainPageViewModel : ViewModelBase +public partial class MainViewModel : ViewModelBase { /// Window/tab title. Cosmetic — bound by /// MainPage.axaml if at all. Not the post title. @@ -120,7 +120,7 @@ public partial class MainPageViewModel : ViewModelBase } - public MainPageViewModel() + public MainViewModel() { SettingsModel = new Settings(); Init(SettingsModel); @@ -170,7 +170,7 @@ public partial class MainPageViewModel : ViewModelBase /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) + public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 9b54a842..40e8548b 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -64,24 +64,6 @@ public partial class Settings : ViewModelBase null); } - /// - /// Returns the canonical Settings instance previously bound through - /// , or null when called - /// outside a running Avalonia application (tests, CLI tools). - /// - public static Settings? GetCurrent() => Volatile.Read(ref s_current); - - /// - /// Resolve the canonical Settings instance or throw. Use this in - /// production code paths that must not silently fall back to a - /// freshly-constructed (which used to be - /// the root cause of the postit://callback crash: two Settings - /// instances racing on PropertyChanged from different threads). - /// - public static Settings RequireCurrent() => - GetCurrent() ?? throw new InvalidOperationException( - "Settings.Current is not bound. Call App.OnFrameworkInitializationCompleted first."); - [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); diff --git a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs index 5ce17aba..4ca69eea 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -4,8 +4,7 @@ namespace PostIt.ViewModels; public abstract partial class ViewModelBase : ObservableObject { - - /// + /// /// Gets if the user can navigate to the next page /// public abstract bool CanNavigateNext { get; protected set; } diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 7eac39ff..7f1f1196 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -8,11 +8,11 @@ xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" x:Class="PostIt.Views.MainPage" - x:DataType="vm:MainPageViewModel" + x:DataType="vm:MainViewModel" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + + Icon="/Assets/avalonia-logo.ico" + Title="PostIt" > - - + -