Compare commits

...

10 commits

Author SHA1 Message Date
e007c7d6eb
refacto static extension for Service provider 2026-08-23 23:07:40 +01:00
d0186d41b0
test are green !?! 2026-08-23 21:28:26 +01:00
cbe307033b
LOGCAT_BOOT_WAIT ?= 30 2026-08-23 18:39:37 +01:00
426bc68d61
more reliable 2026-08-23 18:34:34 +01:00
22e1705e44
Fixes the test suite 2026-08-22 18:31:30 +01:00
151df1c531
build(android): align with .NET 11 SDK + drop unused AndroidX refs
Follow-up to the 0c693237 'sdk version bump' (net10.0 -> net11.0
across the whole solution) and the work to get PostIt.Android building
under the .NET 11 preview SDK installed locally (~/.dotnet):

- Drop Xamarin.AndroidX.Browser and Xamarin.AndroidX.Core.SplashScreen
  from PostIt/Directory.Packages.props. They are not pulled by
  Avalonia.Android 12.1.1 (its nuspec declares AppCompat + Window only),
  are not referenced by any PostIt code, and we already removed the
  SplashScreen XML attributes from styles.xml in commit 6b2867c8.

- PostIt.Android.csproj: bump SupportedOSPlatformVersion 23.0.0 ->
  24.0.0. Required by the Microsoft.Android.Sdk.Linux 37.0.0-preview.7
  target (Android API 24 is the new minimum under .NET 11); below that
  the build emits NETSDK warnings about an EoL target SDK level.

Build status (PostIt.Android, Debug, android-x64, EmbedAssemblies):
  0 errors, 15 warnings, ~4m24s cold. The locally-installed .NET 11
  SDK 11.0.100-preview.7.26381.103 needs /opt/android-sdk/platforms
  /android-37.0 to exist; Paul has set up a symlink to the
  android-37.1 (Android 17 preview) install until Microsoft catches
  up the SDK manifest.

PostIt.Tests: 49/62 passing after the migration; 13 new failures,
most of them HttpListener teardown noise around the OIDC stub
authority suite. Out of scope of this commit.
2026-08-22 18:04:27 +01:00
0c693237a3
sdk version bump 2026-08-22 16:58:08 +01:00
77a7693276
Revert "build(android): downgrade Avalonia 12 -> 11.3.20 + Avalonia.Maui 11.3.0"
This reverts commit a4bc7b5e6a.
2026-08-22 16:09:35 +01:00
a4bc7b5e6a
build(android): downgrade Avalonia 12 -> 11.3.20 + Avalonia.Maui 11.3.0
WIP: addressing the layer-3 WindowingPlatformStub.NotSupportedException
crash on PostIt.Android by downgrading to the Avalonia 11 / Avalonia.Maui
11 stack, where the Android runtime platform layer (IWindowingPlatform)
gets properly bound via the MAUI embedding extension instead of being
left as a stub.

Touched files:

- src/PostIt/Directory.Packages.props:
  - Avalonia 12.1.1 -> 11.3.20 (Avalonia, Avalonia.Android,
    Avalonia.AvaloniaEdit, Avalonia.Browser, Avalonia.Desktop,
    Avalonia.Fonts.Inter, Avalonia.Themes.Fluent).
  - New: Avalonia.Maui 11.3.0.
  - Microsoft.Maui.Essentials 10.0.100 -> 8.0.100 (aligns with the
    Microsoft.Maui.Controls 8.0.x family that Avalonia.Maui 11.3.0
    pulls transitively).
  - New: Microsoft.Maui.Controls 8.0.100.

- src/PostIt/PostIt.Android/PostIt.Android.csproj:
  - TargetFramework net10.0-android -> net9.0-android36.0 (Avalonia
    Android 11.3.20 targets net8.0-android34.0, but the
    compat-ascending fallback lets us consume from net9.0-android36.0
    against the API-36 platform that's already installed locally).
  - Add Avalonia.Maui PackageReference.
  - Restore Microsoft.Maui.Essentials PackageReference (left in
    earlier removal during the MAUI-cause-of-crash hypothesis check).

- src/PostIt/PostIt/PostIt.csproj: TargetFramework net10.0 -> net9.0
  (must match what PostIt.Android consumes transitively, otherwise
  the build chain breaks).

- src/Yavsc.Abstract/Yavsc.Abstract.csproj: net10.0 -> net9.0 (same
  reason; six other consumers stay on net10.0 and pick up the new
  assembly via standard TFM compat).

- src/Yavsc.Api.Client/Yavsc.Api.Client.csproj: net10.0 -> net9.0
  (same reason).

- src/PostIt/PostIt.Android/Application.cs: rewritten as a bare
  Android.App.Application. Avalonia 11 no longer ships
  AvaloniaAndroidApplication<TApp> (that was an Avalonia 12
  introduction); the Avalonia init now lives in MainActivity via
  AvaloniaMainActivity<TApp>. The class is kept only so the
  [Application] manifest entry remains.

- src/PostIt/PostIt.Android/MainActivity.cs: now inherits
  AvaloniaMainActivity<App> and overrides CustomizeAppBuilder to
  chain .UseMaui<PostIt.Maui.MauiEmbeddingApp>(this) before
  .WithInterFont(). AvaloniaMainActivity<TApp> handles the
  AvaloniaView initialisation in OnCreate.

- src/PostIt/PostIt/Maui/MauiEmbeddingApp.cs (new): empty
  Microsoft.Maui.Controls.Application used as the embedding host for
  Avalonia.Maui. The MAUI window it produces is consumed by the
  Avalonia.Maui embedding pipeline and never surfaces to the user;
  Avalonia owns the actual visual tree.

Build status: 11 errors remaining, all CS0234 / CS0246 against
'Avalonia.Controls.ContentPage'. Avalonia 11 did not ship
ContentPage (it's an Avalonia 12 type, presumably for MAUI parity).
All seven PostIt pages inherit ContentPage today and need to be
reworked to UserControl before the build can complete. Follow-up
commit will do that.

Reference: AGENTS.md, 'PostIt.Android boot crash sur AVD x86_64 :
investigation par couches', couche 3.
2026-08-22 15:56:52 +01:00
6b2867c84a
build(android): drop unused Xamarin.AndroidX.SplashScreen resources
- PostIt.Android.csproj: drop Xamarin.AndroidX.Core.SplashScreen and
  Xamarin.AndroidX.Browser. Neither is pulled transitively by
  Avalonia.Android 12.1.1 (its nuspec declares only AppCompat +
  Window), and they are not referenced anywhere in PostIt code.
- Resources/values-v31/styles.xml: drop the windowSplashScreen*
  items and the 'postSplashScreenTheme' reference, which require
  the dropped SplashScreen package. We fall back to the system
  splash screen on Android 12+ devices; a custom one can come
  back when we add it on purpose.
2026-08-22 15:32:43 +01:00
45 changed files with 404 additions and 513 deletions

21
.vscode/launch.json vendored
View file

@ -4,6 +4,21 @@
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug - Android",
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 10000
},
{
"name": "Attach - Android",
"type": "mono",
"request": "attach",
"address": "localhost",
"port": 10000
},
{
"name": "API",
"type": "dotnet",
@ -33,12 +48,10 @@
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [
"--filter-method",
"PostIt.Tests.AndroidAppLaunchTests.PostIt_starts_and_draws_a_first_frame_on_the_emulator"
],
"cwd": "${workspaceFolder}/src/PostIt.Tests",
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
"console": "integratedTerminal",
"stopAtEntry": false
}

23
.vscode/tasks.json vendored
View file

@ -1,6 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "run-debug-android",
"command": "dotnet",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": {
"DOTNET_HOST_PATH": "/usr/share/dotnet",
"ANDROID_HOME": "/opt/android-sdk",
"JAVA_HOME": "/usr/lib/jvm/java-1.21.0-openjdk-amd64"
}
},
"args": [
"build"
"-t:run",
"-p:TargetFramework=net10.0-android",
"-p:Configuration=Debug",
"-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=10000",
"-p:AndroidSdbTargetPort=10000"
],
"problemMatcher": "$msCompile"
},
{
"label": "build",
"command": "dotnet",

View file

@ -161,13 +161,14 @@ 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
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)..."
@ -214,24 +215,25 @@ qemu-build:
-p:RuntimeIdentifier=$(POSTIT_RID) \
-p:EmbedAssembliesIntoApk=true \
--nologo
qemu-install: qemu-build
@if [ ! -f "$(POSTIT_APK)" ]; then \
echo " APK not found at $(POSTIT_APK)." >&2; \
echo " Files in $(POSTIT_APK_DIR):" >&2; \
ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \
exit 1; \
fi
qemu-install: qemu-build
@echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..."
adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)"
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.
@ -239,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); \
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); \
fi
# Clear logcat, launch PostIt.Android, then dump everything that was
@ -263,19 +265,21 @@ 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)"; \
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

@ -1 +0,0 @@
Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d

View file

@ -1,21 +1,37 @@
<Project>
<!-- Pull in shared package versions from the repository root. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<Import
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt-product-specific versions -->
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Browser" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
</ItemGroup>
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Browser" Version="12.1.1" />
<PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
</ItemGroup>
</Project>

View file

@ -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
{

View file

@ -1,8 +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;
namespace PostIt.Android;
@ -12,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
{
/// <summary>
/// <summary>
/// Strongly-typed handle to the current MainActivity instance, set in
/// <see cref="OnCreate"/> and consumed by platform services such as
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch
@ -26,13 +25,11 @@ public class MainActivity : AvaloniaMainActivity
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
{
EmojiCompat.Init(this);
base.OnCreate(savedInstanceState);
PlatformBootstrap.EnsureInitialized();
Current = this;
}
/// <summary>
/// <summary>
/// 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 <c>android://postit-signin?code=...&amp;state=...</c>.

View file

@ -2,35 +2,32 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-android</TargetFramework>
<!-- FORCE LES ARCHITECTURES ANDROID VALIDES -->
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
<SupportedOSPlatformVersion>23.0.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>fr.pschneider.PostIt</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<TrimMode>partial</TrimMode>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link>
</AndroidResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Common" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup>
</Project>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon">
<application android:label="PostIt" android:icon="@drawable/Icon">
<!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow.

View file

@ -40,9 +40,9 @@ public sealed class AndroidSystemBrowser : IBrowser
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true)
.SetShowTitle(true)!
.Build();
tabsIntent.LaunchUrl(_activity, uri);
tabsIntent!.LaunchUrl(_activity, uri);
string responseUri;
try

View file

@ -1,84 +0,0 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices;
using PostIt.Services;
using System.Linq;
namespace PostIt.Android.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
return Array.Empty<ContactDto>();
try
{
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
if (status != PermissionStatus.Granted)
return Array.Empty<ContactDto>();
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// Carry the per-contact email list as-is. A real
// device contact can carry several addresses (home /
// work / other); the UI use case ("invite / add to a
// circle") can then decide which address to use, or
// let the user pick. The platform-neutral ContactDto
// shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count());
foreach (var c in contacts)
{
var emails = ExtractEmails(c.Emails);
result.Add(new ContactDto(
c.Id,
c.DisplayName ?? string.Empty,
emails));
}
return result;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
return Array.Empty<ContactDto>();
}
}
private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
{
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
list.Add(e.EmailAddress);
}
return list;
}
}
#endif

View file

@ -15,7 +15,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -24,7 +24,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -68,9 +68,6 @@ sealed class Program
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View file

@ -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;

View file

@ -1,10 +0,0 @@
<Project>
<!-- Pull in shared package versions from the repository root. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt.Tests-specific versions -->
<ItemGroup>
<PackageVersion Include="Avalonia.Headless" Version="12.0.4" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="12.0.4" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
</ItemGroup>
</Project>

View file

@ -72,7 +72,7 @@ public class MainPageButtonsTests
{ }
}
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
@ -93,7 +93,7 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
@ -109,7 +109,7 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm)
{
var window = new MainWindow();
var page = new MainPage { DataContext = vm };

View file

@ -40,7 +40,7 @@ public class MainPageSaveTests
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
var viewModel = new MainViewModel(blog);
var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it

View file

@ -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;

View file

@ -16,7 +16,9 @@
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
@ -27,6 +29,5 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -16,7 +16,7 @@ public class PostItViewModelTests
// throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
var viewModel = new MainViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });

View file

@ -1,13 +1,16 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt"
x:Class="PostIt.App">
x:Class="PostIt.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. -->
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles>
</Application>

View file

@ -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 PostIt.Helpers;
namespace PostIt;
@ -27,198 +27,70 @@ public partial class App : Application
/// <c>DataValidationErrors.SetErrors</c>.
/// </summary>
public IServiceProvider? ServiceProvider { get; private set; }
private MainWindow window;
public App()
{
}
public MainWindow? Window { get; private set; }
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
}
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);
this.ServiceProvider = new ServiceCollection().BuildServices();
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
var api = ServiceProvider.GetRequiredService<YavscApiClient>();
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<SettingsPage>().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)
{
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
// Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds.
desktop.MainWindow = window;
_ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack
// on every logout — otherwise repeated login/logout would
// eventually balloon the back history.
sessionStatus.LogoutCompleted += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
_ = nav.PopToRootAsync();
};
// When the user signs in interactively (Login button on
// the session banner), push MainPage on top of HomePage.
sessionStatus.LoginSucceeded += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
_ = PushMainPageAsync();
};
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
desktop.MainWindow = CreateMainWindow();
ApplyDarkMode(settings);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleView.MainView = new MainWindow
{
DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>()
};
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
Window = CreateMainWindow();
ApplyDarkMode(settings);
return Window;
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = CreateMainWindow();
ApplyDarkMode(settings);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> 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.
/// </summary>
internal static IServiceProvider BuildServices(ServiceCollection services)
private MainWindow CreateMainWindow()
{
var settings = new Settings();
settings.Load();
Window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
Window.NavRoot.PopToRootAsync();
};
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
sessionStatus.LoginSucceeded += () =>
{
PushMainPageAsync().Wait();
};
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);
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
// Vues
services.AddTransient<MainPage>();
// 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<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// 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<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// 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<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
this.PushPageAsync(homeVm).Wait();
Window.SessionBanner.DataContext = sessionStatus;
return Window;
}
/// <summary>
@ -229,7 +101,7 @@ public partial class App : Application
/// </summary>
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)
@ -266,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.
/// </summary>
public static Task PushMainPageAsync()
public static async Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
return app.PushPageAsync(mainVm);
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
await app.PushPageAsync(mainVm);
}
private bool TryHandOffCustomSchemeUrl()
@ -305,53 +177,8 @@ public partial class App : Application
return true;
}
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal 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 <null>.");
}
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 Task.CompletedTask;
}
return window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
await Window!.NavRoot.PopAsync();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -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<MainPage>();
// 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<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// 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<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// 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<SessionStatusBanner>();
return services.BuildServiceProvider();
}
}

View file

@ -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 <null>.");
}
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);
}
}

View file

@ -1,32 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -38,6 +20,21 @@
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
</Project>

View file

@ -21,6 +21,9 @@ public partial class AuthenticationSettings : ObservableObject
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit";
public static string[] DefaultScopes { get; set; } = { "blogs"} ;
[ObservableProperty]
public partial string Authority { get; set; }

View file

@ -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;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[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<MainPage>(),
MainViewModel => _services.GetRequiredService<MainPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
@ -48,5 +52,5 @@ public class ViewLocator : IDataTemplate
};
}
public bool Match(object? data) => data is ViewModelBase;
public bool Match(object? data) => data is ViewModelBase;
}

View file

@ -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;

View file

@ -8,10 +8,11 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Helpers;
namespace PostIt.ViewModels;
public partial class MainPageViewModel : ViewModelBase
public partial class MainViewModel : ViewModelBase
{
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -120,10 +121,10 @@ public partial class MainPageViewModel : ViewModelBase
}
public MainPageViewModel()
public MainViewModel()
{
Init(null);
SettingsModel = new Settings();
Init(SettingsModel);
BlogClient = null;
}
@ -135,6 +136,11 @@ public partial class MainPageViewModel : ViewModelBase
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
Settings = settings ?? new Settings();
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
// Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh
@ -145,11 +151,7 @@ public partial class MainPageViewModel : ViewModelBase
// sink; that crash is fixed in Settings.OnPropertyChanged
// (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous.
Settings = settings ?? new Settings();
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
}
/// <summary>Save is enabled as soon as the user has typed
@ -169,12 +171,11 @@ public partial class MainPageViewModel : ViewModelBase
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
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)); ;
Services = services;
Init(settings);
}
@ -210,7 +211,7 @@ public partial class MainPageViewModel : ViewModelBase
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient.GetPostsAsync();
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
@ -259,7 +260,7 @@ public partial class MainPageViewModel : ViewModelBase
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
var created = await BlogClient.CreatePostAsync(draft);
var created = await BlogClient!.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
@ -278,7 +279,7 @@ public partial class MainPageViewModel : ViewModelBase
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
@ -297,7 +298,7 @@ public partial class MainPageViewModel : ViewModelBase
await ExecuteAsync(async () =>
{
await BlogClient.DeletePostAsync(SelectedPost.Id);
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
@ -331,7 +332,7 @@ public partial class MainPageViewModel : ViewModelBase
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
@ -372,7 +373,7 @@ public partial class MainPageViewModel : ViewModelBase
private async Task RefreshPostsAsync()
{
var posts = await BlogClient.GetPostsAsync();
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{

View file

@ -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<Settings>()).ConfigureAwait(true);
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
}
}

View file

@ -47,40 +47,7 @@ public partial class Settings : ViewModelBase
/// </summary>
private static Settings? s_current;
/// <summary>
/// Wire the canonical Settings instance to a DI container. Called
/// exactly once from <c>App.axaml.cs</c> 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
/// <c>BindToServiceProvider</c> in a test fixture to silently
/// rebind the production instance.
/// </summary>
public static void BindToServiceProvider(IServiceProvider services)
{
if (services is null) throw new ArgumentNullException(nameof(services));
Interlocked.CompareExchange(ref s_current,
services.GetService<Settings>() ?? throw new InvalidOperationException(
"Settings is not registered in the DI container."),
null);
}
/// <summary>
/// Returns the canonical Settings instance previously bound through
/// <see cref="BindToServiceProvider"/>, or <c>null</c> when called
/// outside a running Avalonia application (tests, CLI tools).
/// </summary>
public static Settings? GetCurrent() => Volatile.Read(ref s_current);
/// <summary>
/// Resolve the canonical Settings instance or throw. Use this in
/// production code paths that must not silently fall back to a
/// freshly-constructed <see cref="Settings"/> (which used to be
/// the root cause of the postit://callback crash: two Settings
/// instances racing on PropertyChanged from different threads).
/// </summary>
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();
@ -99,7 +66,7 @@ public partial class Settings : ViewModelBase
/// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// subscription wired up in
/// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user
/// edit.
@ -350,15 +317,14 @@ public partial class Settings : ViewModelBase
var settings = JsonSerializer.Deserialize<Settings>(json);
if (settings is null)
{
Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source}).");
return;
UseDefaultSettings();
}
// Apply under the gate so concurrent Load() callers cannot
// see half the new values / half the old ones. The actual
// PropertyChanged fan-out is handled by [ObservableProperty]'s
// setters which we route through SetProperty → OnPropertyChanged
// → our overridden dispatcher-safe marshaller below.
lock (_mutationGate)
else lock (_mutationGate)
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
@ -371,6 +337,11 @@ public partial class Settings : ViewModelBase
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
}
else
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
@ -400,6 +371,18 @@ public partial class Settings : ViewModelBase
}
}
private void UseDefaultSettings()
{
this.Authentication = new AuthenticationSettings
{
Authority = AuthenticationSettings.DefaultAuthority,
ClientId = AuthenticationSettings.DefaultClientId,
RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri,
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;
}
/// <summary>
/// Persist the current in-memory state to
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /

View file

@ -4,8 +4,7 @@ namespace PostIt.ViewModels;
public abstract partial class ViewModelBase : ObservableObject
{
/// <summary>
/// <summary>
/// Gets if the user can navigate to the next page
/// </summary>
public abstract bool CanNavigateNext { get; protected set; }

View file

@ -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">
<Design.DataContext>
<vm:MainPageViewModel />
<vm:MainViewModel />
</Design.DataContext>
<Grid Margin="12" RowSpacing="12"

View file

@ -1,27 +1,17 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views"
x:DataType="vm:MainPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainWindow"
>
Icon="/Assets/avalonia-logo.ico"
Title="PostIt" >
<!--
Root layout: persistent session banner on top, navigation
surface below. The banner is the single source of truth for
"Connecté / Déconnecté" and the logout button — visible on
every page (HomePage, MainPage) so the user never has to dig
through a menu to find their session state.
The navigation stack is built programmatically in
App.OnFrameworkInitializationCompleted: HomePage is the
root, MainPage is pushed on top when the silent refresh
succeeds at boot or after a fresh login from HomePage.
-->
<DockPanel LastChildFill="True">
<DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</Window>