Fixes the Android Login process

This commit is contained in:
Paul Schneider 2026-08-25 19:20:03 +01:00
commit 75b298b0f8
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
16 changed files with 106 additions and 129 deletions

14
.vscode/launch.json vendored
View file

@ -5,19 +5,19 @@
"version": "0.2.0",
"configurations": [
{
"name": "Android",
"name": "Android Debug",
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 10000
"port": 55555
},
{
"name": "Attach - Android",
"name": "Android Attach - Debug",
"type": "mono",
"request": "attach",
"address": "localhost",
"port": 10000
"port": 55555
},
{
"name": "API",
@ -26,19 +26,19 @@
"projectPath": "${workspaceFolder}/src/Api/Api.csproj"
},
{
"name": "Yavsc.Org",
"name": "Yavsc Org",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
},
{
"name": "Yavsc.Blogs",
"name": "Yavsc Blogs",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
{
"name": "PostIt",
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",

View file

@ -5,6 +5,7 @@
"appsettings",
"asciidoctor",
"ASPNETCORE",
"Avalonia",
"Configurabilité",
"Cratie",
"DESTDIR",
@ -15,6 +16,7 @@
"Hsts",
"Newtonsoft",
"Npgsql",
"Oidc",
"PKCE",
"postit",
"pschneider",

4
.vscode/tasks.json vendored
View file

@ -18,8 +18,8 @@
"-p:TargetFramework=net10.0-android",
"-p:Configuration=Debug",
"-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=10000",
"-p:AndroidSdbTargetPort=10000"
"-p:AndroidSdbHostPort=55555",
"-p:AndroidSdbTargetPort=55555"
],
"problemMatcher": [
{

View file

@ -1,13 +1,11 @@
using Android;

using Android.App;
using Android.Content;
using Android.Content.PM;
using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text;
using Avalonia;
using Avalonia.Android;
using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text;
using PostIt.Droid.Services;
namespace PostIt.Android;
@ -34,7 +32,7 @@ public class MainActivity : AvaloniaMainActivity
Yavsc.Resource.Array.com_google_android_gms_fonts_certs); //com_google_android_gms_fonts_certs
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
EmojiCompat.Init(config);
PlatformBootstrap.EnsureInitialized();
PlatformBootstrap.InitPlatform();
base.OnCreate(savedInstanceState);
Current = this;
}
@ -50,7 +48,13 @@ public class MainActivity : AvaloniaMainActivity
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
if (intent is not null) AndroidOidcCallbackSink.Handle(intent);
var url = intent?.DataString;
if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback"))
{
OidcCallbackManager.SetResult(url);
}
}
internal static class AndroidOidcCallbackSink

View file

@ -12,14 +12,9 @@ namespace PostIt.Android;
/// </summary>
internal static class PlatformBootstrap
{
private static int _initialized;
internal static void EnsureInitialized()
internal static void InitPlatform()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri;
Platform.CreateBrowser = () =>
{
var activity = MainActivity.Current;

View file

@ -2,31 +2,5 @@
<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">
<!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow.
After the user authenticates in the system browser, the OP
redirects to android://postit-signin?... and Android forwards
the Intent to the MainActivity (configured SingleTask so the
existing instance receives OnNewIntent rather than spawning a
new one).
The host value (postit-signin) MUST match the
AndroidRedirectUri constant in PostIt/Settings/Settings.cs and
the corresponding RedirectUri registered for the 'postit'
client in IdentityServer (Yavsc.Org ConfigurationDb).
-->
<activity-alias
android:name="PostIt.Android.OidcCallbackActivity"
android:targetActivity="PostIt.Android.PostItMainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="android" android:host="postit-signin" />
</intent-filter>
</activity-alias>
</application>
</manifest>

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Android.App;
using AndroidX.Browser.CustomTabs;
using IdentityModel.OidcClient.Browser;
using PostIt.Droid.Services;
namespace PostIt.Android.Services;
@ -35,9 +36,14 @@ public sealed class AndroidSystemBrowser : IBrowser
};
}
var uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
// 1. Enregistrez la tâche avant de lancer le Custom Tab
var callbackTask = OidcCallbackManager.RegisterCallback(cancellationToken);
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
// 2. LANCEZ VOTRE CUSTOM TAB ICI (via AndroidX.Browser.CustomTabs)
// ... code pour ouvrir l'URL d'authentification ...
var uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true)!

View file

@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Droid.Services;
public static class OidcCallbackManager
{
private static TaskCompletionSource<string>? _tcs;
public static Task<string> RegisterCallback(CancellationToken cancellationToken)
{
_tcs = new TaskCompletionSource<string>();
cancellationToken.Register(() => _tcs.TrySetCanceled());
return _tcs.Task;
}
public static void SetResult(string url)
{
_tcs?.TrySetResult(url);
}
}

View file

@ -0,0 +1,35 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using PostIt.Droid.Services;
namespace PostIt.Android;
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "postit", // Remplacez par votre schéma personnalisé (ex: yavsc ou postit)
DataHost = "callback")] // Correspond à postit://callback
public class WebAuthenticationCallbackActivity : Activity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
// Capturer l'URL de redirection OIDC
var url = Intent?.DataString;
if (!string.IsNullOrEmpty(url))
{
// Transmettre l'URL au gestionnaire partagé pour compléter la Task
OidcCallbackManager.SetResult(url);
}
// Fermer cette activité transparente et ramener l'application au premier plan
var intent = new Intent(this, typeof(MainActivity));
intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
StartActivity(intent);
Finish();
}
}

View file

@ -1,29 +0,0 @@
using PostIt.Services;
namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared OIDC login path sees a working <c>IBrowser</c> — the
/// custom-scheme browser that hands the OIDC callback off to the
/// running instance through the named pipe. Desktop builds do NOT use
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user
/// has configured to open it.
/// </summary>
internal static class PlatformBootstrap
{
private static int _initialized;
internal static void EnsureInitialized()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
// Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit";
}
}

View file

@ -12,8 +12,6 @@ sealed class Program
[STAThread]
public static void Main(string[] args)
{
PlatformBootstrap.EnsureInitialized();
// Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes

View file

@ -85,7 +85,7 @@ public class SettingsLoadTests
bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip;
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DefaultDesktopRedirectUri;
global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"

View file

@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@ -73,10 +72,8 @@ public partial class App : Application
ConfigureRootView(View);
ApplyDarkMode(settings);
}
var mainVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
base.OnFrameworkInitializationCompleted();
this.PushPageAsync(mainVm);
}
private void ConfigureRootView(MainView rootView)
@ -93,9 +90,9 @@ private void ConfigureRootView(MainView rootView)
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += () =>
sessionStatus.LoginSucceeded += async () =>
{
PushMainPageAsync();
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus;
@ -133,6 +130,9 @@ private void ConfigureRootView(MainView rootView)
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh();
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true);

View file

@ -21,14 +21,14 @@ public static class Platform
/// override this property at startup (e.g. PostIt.Android sets
/// it to <c>android://postit-signin</c>).
/// </summary>
public static string DefaultRedirectUri { get; set; } = "postit://callback";
public const string RedirectUri = "postit://callback";
/// <summary>
/// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches
/// against <c>BrowserOptions.EndUrl</c>. Overridable for apps
/// that want to register their own scheme.
/// </summary>
public static string CustomScheme { get; set; } = "postit";
public const string CustomScheme = "postit";
/// <summary>
/// Constructs a fresh <see cref="IBrowser"/> for the running platform.
@ -37,4 +37,4 @@ public static class Platform
/// </summary>
public static System.Func<IBrowser?>? CreateBrowser { get; set; } =
() => new CustomSchemeBrowser(CustomScheme);
}
}

View file

@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
public const string DesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
@ -34,15 +34,19 @@ public partial class AuthenticationSettings : ObservableObject
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// OAuth redirect URI. Defaults to <see cref="DesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
public partial string RedirectUri { get; set; }
#if ANDROID
= AndroidRedirectUri;
#else
= DesktopRedirectUri;
#endif
/// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the

View file

@ -2,13 +2,11 @@ using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@ -18,37 +16,6 @@ public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
/// at application boot by <see cref="App.OnFrameworkInitializationCompleted"/>
/// through <see cref="BindToServiceProvider"/>. The hybrid pattern:
/// <list type="bullet">
/// <item><description>The static <c>Current</c> reference gives
/// ViewModels a non-DI way to reach the same instance (and lets
/// the framework bindings push notifications through one stable
/// <see cref="ObservableObject"/>).</description></item>
/// <item><description>Tests that want to exercise a clean
/// instance still call <c>new Settings()</c>; <c>Current</c>
/// stays null in those contexts because <see cref="BindToServiceProvider"/>
/// is never invoked.</description></item>
/// <item><description>Reads (<see cref="GetCurrent"/>) are
/// thread-safe and never allocate; mutations always go through
/// the DI-resolved singleton so two threads cannot each register
/// a different "current" Settings.</description></item>
/// </list>
/// </summary>
private static Settings? s_current;
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
@ -66,7 +33,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
/// 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.
@ -336,7 +303,7 @@ public partial class Settings : ViewModelBase
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri;
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
@ -377,7 +344,7 @@ public partial class Settings : ViewModelBase
{
Authority = AuthenticationSettings.DefaultAuthority,
ClientId = AuthenticationSettings.DefaultClientId,
RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri,
RedirectUri = AuthenticationSettings.DesktopRedirectUri,
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;