Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ed75f06fb2 |
|||
|
dfef7652a4 |
|||
|
d476c08046 |
|||
|
d192705f29 |
|||
|
e7e6b375f5 |
|||
|
0893805ac4 |
|||
|
0d79ba89a6 |
|||
|
9c0c60ebe7 |
|||
|
0e9f61bd24 |
|||
|
10d992a07a |
|||
|
ad92744a94 |
|||
|
2dcbcd35b3 |
|||
|
5d03b2e477 |
|||
|
ed51e62b29 |
|||
|
4fc91cec3e |
|||
|
d9b183c500 |
|||
|
f63b04d25b |
|||
|
b68bc7b328 |
|||
|
04c42d9380 |
|||
|
ba384ba4c0 |
|||
|
08a3541749 |
|||
|
cb8cf9406d |
|||
|
b90f15333d |
|||
|
dc4412b9e8 |
|||
|
c1254a522f |
|||
|
40eb5086c1 |
|||
|
d24244aae6 |
199 changed files with 22893 additions and 966 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -34,6 +34,7 @@ appsettings-*.*.json
|
|||
!src/Yavsc.Org/appsettings-org.Testing.json
|
||||
|
||||
generated/
|
||||
/contrib/Windows/PostIt-*-Setup.exe
|
||||
*.tmp
|
||||
tmp/
|
||||
DataDir/
|
||||
|
|
|
|||
204
CHANGELOG.md
204
CHANGELOG.md
|
|
@ -1,5 +1,209 @@
|
|||
|
||||
# Changelog
|
||||
|
||||
## [1.0.8-rc33] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt.Android] Avoid applying camera sensor rotation twice: account for the rotation already included in Camera2's SurfaceTexture matrix before rotating encoder input, while retaining portrait output dimensions.
|
||||
* [PostIt.Android] Fix zero-second camera captures after adding OpenGL rotation: align Camera2 timestamps with the recorder's monotonic clock, preserve frame intervals, ignore duplicate frames and start camera requests only after the encoder is ready. Add a real Camera2/microphone regression check for segment duration and H.264/AAC tracks.
|
||||
* [PostIt.Android] Keep the `VideoLAN.LibVLC.Android` JNI/native bindings alive in Release builds with a ProGuard rule set so the bundled `libvlc`/`libc++_shared` libraries are not stripped.
|
||||
* [PostIt.Android] Rotate camera frames before H.264 encoding using an OpenGL surface, based on sensor orientation, lens facing and display rotation at capture start. Portrait captures swap the encoded dimensions; live MPEG-TS/HLS and replays no longer depend on an ineffective MP4-only orientation hint. Restore compilation by reading display orientation from the activity rather than the capture service.
|
||||
|
||||
## [1.0.8-rc32] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* Performer notification audience
|
||||
* [PostIt.Android] Upgrade `VideoLAN.LibVLC.Android` to 3.7.0-beta to resolve the runtime `failed to load libc++_shared.so` problem on 16 KiB-page Android devices and document the native-library compatibility requirement.
|
||||
|
||||
## [1.0.8-rc31] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [Yavsc.Org] Permalien public de podcasts performer avec jeton a durée
|
||||
limitée, page Razor de presentation et bandeau de prospection vers
|
||||
l'inscription Yavsc et le téléchargement de PostIt.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* Podcast availability from Android
|
||||
* avoid NRE in MailSender on rdv query notifications
|
||||
* Android emulator smoke test skipped in CI because Calabash instrumentation signature mismatches the target APK on hosted runners
|
||||
|
||||
## [1.0.8-rc30] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* Podcast availability from Android
|
||||
|
||||
## [1.0.8-rc29] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt.Android] Lecture des podcasts directs et replays dans la page
|
||||
video partagée avec le desktop : surface LibVLC native Android et moteur
|
||||
embarque, transport authentifie commun et contrôles uniques. Liberation
|
||||
du lecteur en arrière-plan ; réouverture du flux au retour dans l'application.
|
||||
|
||||
* [PostIt.Android] Emission depuis la camera et le microphone Android
|
||||
(Android 8 minimum), encodage H.264/AAC natif et segmentation MPEG-TS
|
||||
sur images clés. Interface, envoi authentifie, retries et finalisation
|
||||
du replay partages avec le desktop. Service de premier plan avec
|
||||
notification d'arrêt et maintien de la capture en arrière-plan.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Mise a jour d'Avalonia vers 12.1.4.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Api, Yavsc.Server] Une demande de rendez-vous ne provoque plus
|
||||
d'erreur 500 (NullReferenceException dans MailSender) : l'API lie
|
||||
désormais la section Smtp, le modèle appsettings-api fournit Site.Owner
|
||||
et Smtp, et un envoi sans configuration mail complete est journalise
|
||||
puis ignore.
|
||||
|
||||
|
||||
## [1.0.8-rc28] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [Yavsc.Api, Yavsc.Server, Yavsc.Api.Client] Creation explicite et
|
||||
idempotente d'une facture depuis un devis doublement signe, avec lignes,
|
||||
total et parties figes. Historique des encaissements hors ligne attestes
|
||||
par le prestataire et des transactions PayPal vérifiées cote serveur ;
|
||||
references uniques, cumul atomique et protection contre les écritures
|
||||
concurrentes. Routes documentées, client HTTP et migration associes.
|
||||
|
||||
* [Yavsc.Abstract, Yavsc.Server] Modèle de solde des commandes facturables :
|
||||
montants dus et payes persistants, reste a payer et états non paye,
|
||||
partiellement paye ou acquitte. L'acquittement depend des montants,
|
||||
et non du seul statut PayPal ; migration sans montant historique invente.
|
||||
|
||||
* [PostIt.Desktop, Yavsc.Api] Podcasts en direct avec replay HLS conserve :
|
||||
capture camera et microphone par LibVLC (H.264/AAC, segments MPEG-TS),
|
||||
envoi ordonne et idempotent, catalogue et lecture authentifiée dans PostIt.
|
||||
L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur
|
||||
disque, quotas dédiés et conservation des segments reçus apres interruption.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Facturation] Les commandes facturées et leur devis ne peuvent plus être
|
||||
supprimes via les routes de commande usuelles. Les additions distinguent
|
||||
montant verse, solde restant et acquittement ; les anciennes routes de
|
||||
generation exigent une facture établie depuis un devis signe.
|
||||
|
||||
## [1.0.8-rc27] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans
|
||||
elevation : publication autonome, raccourci du menu Démarrer,
|
||||
protocole `postit://` pour la connexion et désinstallation via Windows.
|
||||
Instructions de generation ajoutées dans `INSTALL.md`.
|
||||
|
||||
* [PostIt, Yavsc.Api] Portage des outils des contrôleurs web
|
||||
d’administration : tableau de bord, rôles et affectations, clients OIDC
|
||||
(paramètres, collections et secrets), scopes API et modèles de courriels.
|
||||
Le dernier administrateur ne peut pas être exclu ; les suppressions et
|
||||
rotations de secrets demandent une confirmation dans PostIt.
|
||||
* [Yavsc.Api] Routes `api/v1/administration`, DTO dédiés, client
|
||||
`AdministrationApiClient` et documentation OpenAPI protégée en développement.
|
||||
Les secrets OIDC sont hachés en SHA-256 et seule leur création restitue
|
||||
la valeur en clair, sans cache.
|
||||
* [PostIt, Yavsc.Api] Export et import JSON transactionnel des données
|
||||
métier des comptes, billets et activités. Les comptes sont transférés
|
||||
sans mots de passe, jetons, confirmations ou rôles ; les contrôles
|
||||
d’accès des billets sont conservés et leurs références sont validées.
|
||||
|
||||
* [PostIt] Service `CameraStreamer` (préliminaire) : détection des
|
||||
périphériques de capture vidéo (DirectShow sous Windows via le paquet
|
||||
`DirectShowLib`, `/dev/video*` sous Linux) et création d'un média
|
||||
LibVLC de capture pour le périphérique choisi.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Une seule page Administration regroupe les utilisateurs et
|
||||
leurs usages, avec le détail d’usage de l’utilisateur sélectionné.
|
||||
L’accès depuis l’accueil et sa commande sont désormais réservés au rôle `Administrator`,
|
||||
vérifié via le profil du backend. « Mon profil » conserve la gestion
|
||||
du compte et accueille l’usage personnel, sans consulter les usages globaux.
|
||||
* [Yavsc.Api] Les listes d’utilisateurs et d’usages globaux sont désormais
|
||||
réservées au rôle `Administrator`, comme les autres outils d’administration.
|
||||
|
||||
* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de
|
||||
la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et
|
||||
liste à droite, avec séparateur vertical redimensionnable. La disposition
|
||||
verticale revient en portrait sans perdre le brouillon ; la barre de
|
||||
navigation est plus compacte en mode large et conserve le bouton Retour.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Yavsc.Server] Reception WebSocket historique : correction du tampon
|
||||
reutilise, suppression de la file non synchronisee et liberation des
|
||||
connexions. Les podcasts utilisent desormais une ingestion HTTP bornee.
|
||||
|
||||
* [PostIt.Android] Restauration de la pile de navigation lors de la
|
||||
recréation de l'activité après suspension : nouvelles pages liées aux
|
||||
mêmes modèles de vue, sans plus de procès.
|
||||
* [PostIt] Le changement de disposition de la page des blogs est différé
|
||||
sur le thread UI, évitant l'exception « Visual was invalidated during
|
||||
the render pass » au redimensionnement.
|
||||
* [PostIt] Le bouton d'enregistrement des billets reflète les modifications
|
||||
non sauvegardées et réinitialise son surlignage après sauvegarde, sans
|
||||
fermer l'éditeur ni perdre la sélection.
|
||||
|
||||
## [1.0.8-rc26] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur,
|
||||
date et contenu Markdown défilant ; l'édition et la création restent
|
||||
accessibles par des actions explicites, sans perdre le brouillon en
|
||||
passant de la lecture à l'édition.
|
||||
* [PostIt] Résolution des liens et images relatifs depuis
|
||||
`<autorité Yavsc.Org>/files/<login auteur>/`, reconnaissance des fichiers
|
||||
utilisateur sur cette origine et détection du type MIME par HTTP.
|
||||
* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop,
|
||||
avec lecture/pause, libération du lecteur à la fermeture et accès au
|
||||
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
||||
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
|
||||
* [Yavsc.Api] Contrôleur `DismissClicksApiController` (déplacé depuis
|
||||
Yavsc.Org) exposant des DTO `DismissedNotification` :
|
||||
`GET api/v1/dismiss`, `POST api/v1/dismiss/click/{notificationId}`
|
||||
(idempotent, 404 si la notification n'existe pas) et
|
||||
`DELETE api/v1/dismiss/click/{notificationId}` ; authentification requise.
|
||||
* [Yavsc.Api.Client] `NotificationsApiClient.DismissAsync`,
|
||||
`GetDismissedAsync` et `RestoreAsync`.
|
||||
* [PostIt] Bouton de fermeture sur chaque notification de l'accueil
|
||||
qui la démet auprès de Yavsc.Api pour un utilisateur connecté, ou la
|
||||
masque localement sans appel API pour un visiteur non connecté
|
||||
(jusqu'au prochain rechargement des notifications).
|
||||
|
||||
### Changed
|
||||
|
||||
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
|
||||
* [Yavsc.Org] L'API `api/v1/dimiss` est supprimée ; le site web démet ses
|
||||
notifications via `POST /Home/DismissNotification/{id}` (base pour un
|
||||
utilisateur connecté, session pour un visiteur anonyme).
|
||||
|
||||
## [1.0.8-rc25] - unstable
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
|
||||
<PackageVersion Include="Markdig" Version="1.4.1" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
||||
|
|
@ -15,34 +15,35 @@
|
|||
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
|
||||
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.3.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
|
||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
|
|
@ -52,15 +53,15 @@
|
|||
|
||||
|
||||
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
|
||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.77.0.4285" />
|
||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="15.0.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.13" />
|
||||
|
||||
|
||||
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.5.1" />
|
||||
|
||||
<PackageVersion Include="MimeKit" Version="4.18.1" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
|
|
|
|||
202
INSTALL.md
202
INSTALL.md
|
|
@ -29,6 +29,208 @@ dotnet publish -c Release
|
|||
and then, install the app, from the `src/PostIt/PostIt.Android/bin/Release/net10.0/PostIt.apk`
|
||||
|
||||
|
||||
## PostIt.Desktop on Windows (per-user installer)
|
||||
|
||||
Build prerequisites: the .NET 10 SDK and NSIS 3 (with its standard
|
||||
Modern UI 2 includes). From the repository root, publish a self-contained
|
||||
Windows x64 application, then package the **entire** publish directory:
|
||||
|
||||
```powershell
|
||||
dotnet publish src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false
|
||||
makensis /DAPP_VERSION=1.1.0-estimate.1 contrib/Windows/postit.nsi
|
||||
```
|
||||
|
||||
Set `APP_VERSION` to the version of the published application (the
|
||||
`Version` property in `PostIt.Desktop.csproj`). NSIS requires this value;
|
||||
it is used in the installer filename and Windows' installed-apps list.
|
||||
On Linux, use `makensis -DAPP_VERSION=1.1.0-estimate.1 contrib/Windows/postit.nsi`
|
||||
instead. Cross-publishing uses the same `dotnet publish` command.
|
||||
If `makensis` is not on `PATH` on Windows, invoke the executable from
|
||||
the NSIS installation directory.
|
||||
|
||||
The result is `contrib/Windows/PostIt-<version>-win-x64-Setup.exe`.
|
||||
NSIS also accepts `PUBLISH_DIR` (an absolute path to a different
|
||||
**win-x64** publish directory) and `OUTPUT_FILE` (the output executable
|
||||
path), with the same `/DNAME=value` or `-DNAME=value` syntax. Quote an
|
||||
entire define argument when its value contains spaces.
|
||||
|
||||
The installer does not request elevation or install a shared .NET runtime.
|
||||
It installs in `%LOCALAPPDATA%\Yavsc\PostIt.Desktop`, creates a Start menu
|
||||
shortcut for the current user and registers `postit://` in
|
||||
`HKCU\Software\Classes` so browser-based sign-in returns to PostIt.
|
||||
Windows' installed-apps list provides the uninstaller. Close PostIt before
|
||||
installing an update or uninstalling it.
|
||||
|
||||
The `app` subdirectory is reserved for published application files and is
|
||||
removed recursively on uninstall. User settings and tokens stored outside
|
||||
this directory are preserved. Reinstalling uses the recorded installation
|
||||
directory and overwrites the packaged files.
|
||||
|
||||
The self-contained publication includes .NET, but does **not** add native
|
||||
LibVLC libraries: video playback still requires a compatible Windows x64
|
||||
LibVLC installation. PostIt reports an unavailable video player and offers
|
||||
the system browser as an alternative.
|
||||
|
||||
Before distributing a release, test installation, startup, the `postit://`
|
||||
login callback, updating and uninstallation on Windows with a standard
|
||||
(non-administrator) account. Installer compilation alone does not validate
|
||||
these Windows runtime behaviours.
|
||||
|
||||
## Podcast live broadcasts and replays
|
||||
|
||||
PostIt Desktop's home page provides **Podcasts : directs et replays**.
|
||||
Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the
|
||||
Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the
|
||||
default PulseAudio microphone. FFmpeg is **not** a runtime dependency.
|
||||
Select a camera and optionally a microphone, enter a title, then start.
|
||||
Stopping publishes the retained replay; navigating away or logging out
|
||||
stops capture too. A second signed-in PostIt instance can watch the live
|
||||
broadcast while it is being captured.
|
||||
|
||||
Both upload and playback require a bearer token with the `api` scope.
|
||||
All authenticated API users can watch the catalogue, live broadcasts and
|
||||
replays; only the broadcaster can upload, complete or delete their podcast.
|
||||
Media is not anonymous and no access token is included in a playback URL.
|
||||
|
||||
Configure Yavsc.Api's `Podcast` section (or environment variables such as
|
||||
`Podcast__RootPath`). No database migration or server-side encoder is needed:
|
||||
|
||||
```json
|
||||
{
|
||||
"Podcast": {
|
||||
"RootPath": "/srv/yavsc-private/podcasts",
|
||||
"MaxSegmentBytes": 8388608,
|
||||
"MaxOwnerBytes": 1073741824,
|
||||
"MaxSegments": 7200,
|
||||
"MaxOwnerPodcasts": 100,
|
||||
"IdleTimeoutSeconds": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RootPath` must be absolute, writable by Yavsc.Api and **outside any static
|
||||
file directory**. The default is `DataDir/podcasts` under the API application
|
||||
base directory. Use a persistent volume and back up this entire directory
|
||||
(indexes and segments together). This implementation supports **one API
|
||||
process per storage root**, not multiple writers or replicas.
|
||||
|
||||
Defaults allow 8 MiB per segment (also the HTTP endpoint's hard limit),
|
||||
1 GiB retained per owner, 7,200 segments per episode and 100 episodes per
|
||||
owner. At the target two-second segment duration, this is roughly four hours
|
||||
per episode; the byte quota can be reached earlier. Podcast quotas are
|
||||
separate from the legacy user-files `DiskQuota`. Deleting an ended podcast
|
||||
releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body
|
||||
and forward Authorization on every API request.
|
||||
|
||||
A broadcast without a new committed segment for 60 seconds, or present
|
||||
when the API restarts, becomes an interrupted replay on the next catalogue,
|
||||
manifest or upload request. Already committed media remains playable.
|
||||
The broadcaster retries transient upload errors twice using the same
|
||||
sequence and content; it stops if capture fails or the pending local media
|
||||
exceeds 64 MiB. Unacknowledged media is retained under
|
||||
`%LOCALAPPDATA%\PostIt\podcast-capture` (Linux: the corresponding local
|
||||
application data directory), with `podcast.json` and the capture playlist
|
||||
for manual recovery. There is no automatic resumption after an application
|
||||
restart; these private local recordings may be removed manually when no
|
||||
longer needed.
|
||||
|
||||
The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated
|
||||
player joins a live broadcast up to three segments behind the edge and
|
||||
reads a replay from its beginning; seeking is not implemented. Native
|
||||
camera/microphone permissions, drivers and codecs must still be verified
|
||||
on each target Windows/Linux machine.
|
||||
|
||||
### Android podcasts
|
||||
|
||||
The Android APK bundles LibVLC and uses a native Android video surface inside
|
||||
the same Avalonia video page as desktop. Catalogue, playback controls and
|
||||
authenticated segment downloads are shared; no external browser, public media
|
||||
URL or camera/microphone permissions are needed to watch a podcast.
|
||||
|
||||
Leaving the app stops playback and cancels downloads. Returning recreates the
|
||||
player: a replay starts from the beginning, and a live stream rejoins near the
|
||||
live edge. Seeking and background audio are not supported.
|
||||
|
||||
Broadcasting requires **Android 8 (API 26) or newer**. In the shared podcast
|
||||
page, detect devices, select front/rear camera and optionally the microphone,
|
||||
then start the live broadcast while PostIt is visible. Camera permission,
|
||||
microphone permission when selected, and notification permission on Android 13+
|
||||
must be granted. A permission denial is reported in the page without starting
|
||||
capture or creating a server broadcast. Permissions are requested before the
|
||||
server session is created so a long permission dialog cannot expire it.
|
||||
Watching does not request these permissions.
|
||||
|
||||
Android uses Camera2 and its native MediaRecorder H.264/AAC encoder, up to 480p at 30 fps,
|
||||
not the desktop LibVLC camera inputs or software encoders. MPEG-TS is segmented
|
||||
at IDR frames, with timestamps used for segment durations. Closed segments
|
||||
enter the same ordered upload/retry/quota/replay pipeline as desktop.
|
||||
Devices without a supported camera/encoder report a startup error.
|
||||
|
||||
Camera frames are rotated through an OpenGL surface before encoding, so both
|
||||
live playback and replays have upright pixels without relying on rotation
|
||||
metadata (not supported by the MPEG-TS recorder). The sensor, front/rear lens
|
||||
and display orientation are sampled when capture starts; portrait output swaps
|
||||
width and height. Camera2 may already include sensor rotation in the
|
||||
SurfaceTexture transform; only the remaining rotation is applied by OpenGL.
|
||||
Keep that device orientation during a broadcast, or stop and
|
||||
start a new broadcast after turning the device.
|
||||
|
||||
A camera/microphone foreground service and a partial wake lock maintain the
|
||||
broadcast when PostIt is backgrounded or the screen locks. Its ongoing
|
||||
notification returns to the app and offers **Arreter et publier**. Stopping
|
||||
flushes the final segment before completing the replay. Leaving the podcast
|
||||
page or logging out still stops capture. Android's explicit service stop,
|
||||
process termination or application restart does not automatically restart a
|
||||
camera; segments already received by the server remain available under the
|
||||
interrupted-replay rules above. Device power policies can impose additional
|
||||
restrictions and must be tested on the intended physical devices.
|
||||
|
||||
**Native library compatibility:** the Android app now uses
|
||||
`VideoLAN.LibVLC.Android` 3.7.0-beta, which is the minimum compatible package
|
||||
for 16 KiB-page devices. Older 3.6.5 binaries can fail at runtime with
|
||||
`failed to load libc++_shared.so` or the linker error `XA0141` because the
|
||||
native libraries are not aligned for 16 KiB memory pages. A successful APK
|
||||
build or a test on a 4 KiB-page emulator does not establish compatibility
|
||||
for 16 KiB-page devices or Play distribution requiring that alignment.
|
||||
|
||||
Build with the Android workload and SDK configured:
|
||||
|
||||
```bash
|
||||
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj
|
||||
```
|
||||
|
||||
On an Android device, verify replay video/audio, live playback, pause/resume,
|
||||
rotation, Back navigation and returning after backgrounding the app. Also
|
||||
verify an expired session or interrupted network reports a playback error.
|
||||
For broadcasting, test front/rear cameras, microphone enabled/disabled,
|
||||
permission denial, screen lock, background capture, notification stop and the
|
||||
final replay. No automatic retry of a denied permission or a killed camera
|
||||
service is performed.
|
||||
|
||||
The Debug APK includes a local native-capture test activity (excluded from
|
||||
Release); it does not create a server podcast. On a dedicated emulator, grant
|
||||
camera, audio and notification permissions, then run:
|
||||
|
||||
```bash
|
||||
adb shell am start -W -f 0x18000000 -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity
|
||||
# Wait about 20 seconds for foreground/background recording and final flush.
|
||||
adb shell run-as fr.pschneider.postit cat files/podcast-capture-test-result.txt
|
||||
# Repeat with the second camera, no audio, and the notification stop action:
|
||||
adb shell am start -W -f 0x18000000 -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity --es microphone false --es camera front --es notificationStop true
|
||||
```
|
||||
|
||||
The result reports the segment count, the background progress and the private
|
||||
recording directory under `files/podcast-capture-tests`. Recordings remain
|
||||
there for local decoder inspection and can be removed after the test.
|
||||
|
||||
The implementation was checked on an Android 16 x64 emulator with 4 KiB
|
||||
pages: camera/microphone capture produced five independently decodable
|
||||
H.264/AAC segments across foreground/background operation; the second camera
|
||||
without audio produced six decodable H.264 segments, including screen-off
|
||||
operation and stop through the notification action. The foreground service
|
||||
was gone after both tests. These local tests do not replace an authenticated
|
||||
Android-to-server broadcast/replay test or validation on physical devices.
|
||||
|
||||
## Resources
|
||||
|
||||
System resources needed to install.
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
<clear />
|
||||
<add key="isn" value="https://isn.pschneider.fr/api/v3/index.json" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="videolan-preview" value="https://f.feedz.io/videolan/preview/nuget/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
|
|
|
|||
|
|
@ -138,11 +138,9 @@ Dans le cas des arrhes, à tout moment, jusqu'avant la date et l'heure de la pre
|
|||
|
||||
## Limitations
|
||||
|
||||
* **Aujourd'hui** : une prestation par commande, sur un axe client → prestataire unique, sans sous-traitance. **Cible roadmap** : montages multi-parties (plusieurs clients et/ou plusieurs fournisseurs collaborant autour d'un même projet, avec sous-traitance validée par le client) — voir la [ROADMAP](../ROADMAP.md).
|
||||
* **Annulation : partielle et non testée.** Côté client, `HairCutCommandController.ClientCancel` / `ClientCancelConfirm` supprime la commande **sans déclencher de refund PayPal** ni logique arrhes vs avance ; aucune action d'annulation côté prestataire. La description du README (arrhes +20%, avance non-annulable) **n'est pas implémentée**. Cible : câbler `RefundTransaction` et le workflow complet, voir [ROADMAP](../ROADMAP.md).
|
||||
* **Réclamation post-prestation : aucun support applicatif.** Passée la date de la prestation, le client n'a aucun chemin dans l'UI pour signaler un litige — aucune route, aucun contrôleur, aucun modèle. La fonctionnalité de conciliation est planifiée au **Jalon 5** (cf. [doc/ddd-exploration-2026-06-14.md](../doc/ddd-exploration-2026-06-14.md)) : double vue client/prestataire, dossier de conciliation, décision. En attendant, tout passe par e-mail au propriétaire de l'installation.
|
||||
* **Paiement : PayPal uniquement, via l'API NVP/SOAP.** Aucun autre PSP n'est intégré (pas de Stripe, pas d'Adyen). L'API NVP/SOAP que PayPal a dépréciée au profit de REST reste fonctionnelle mais n'est plus recommandée par PayPal ; aucune migration n'est planifiée à court terme.
|
||||
* **Périmètre métier étroit.** Pas de saisie de structures de projets complexes ; pas de ticketing associé à la prestation.
|
||||
* **Collecte centralisée.** Tous les paiements PayPal transitent par le compte du propriétaire de l'installation — c'est le seul identifiant configuré côté PSP. Les professionnels apparaissent comme tierces parties du point de vue PayPal.
|
||||
* **Pas de gestion de paie.** Aucune édition de fiche de paye, ni paiement en masse. Seuls les paiements unitaires sus-cités sont supportés.
|
||||
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -109,14 +109,14 @@ Sous-jalons du `TODO.fr.md.archive` repris ici :
|
|||
|
||||
Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) :
|
||||
|
||||
- ☐ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔)
|
||||
- ☐ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile)
|
||||
- ☐ **Création d'une annonce**
|
||||
- ✔ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔)
|
||||
- ✔ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile)
|
||||
- ◐ **Création d'une annonce**
|
||||
- ☐ **Transformation annonce ↔ demande nominative**
|
||||
- ☐ **Sélection d'une star** depuis le **mobile** (Web ✔)
|
||||
- ◐ **Sélection d'une star** depuis le **mobile** (Web ✔)
|
||||
- ☐ **Sélection DJ / chanteur / formation** (recherche par critères : date, lieu, tendance, taille, répertoire) depuis le **mobile**
|
||||
- ☐ **Géocodeur de lieu** côté mobile
|
||||
- ☐ **Géocodeur d'adresse postale** (Web + Mobile)
|
||||
- ◐ **Géocodeur d'adresse postale** (Web + Mobile)
|
||||
- ☐ **Évaluation des trajets**
|
||||
- ☐ **Interface de gestion des collaborateurs** (sous-traitance — déjà amorcée dans `doc/Architecture.md`)
|
||||
- ☐ **Migration de l'identifiant utilisateur** : `string` GUID → `long` auto-incrémenté (Npgsql)
|
||||
|
|
@ -163,7 +163,7 @@ Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) :
|
|||
- ☐ **Aide à la rencontre physique** : carte interactive, geofencing, alertes
|
||||
- ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE)
|
||||
- ☐ **Streaming vidéo pair-à-pair**
|
||||
- ☐ **Streaming vidéo public**
|
||||
- ✔ **Streaming vidéo public**
|
||||
|
||||
**Critère de sortie** : un événement géolocalisé notifie ses participants confirmés à l'approche du point de rendez-vous.
|
||||
|
||||
|
|
|
|||
114
contrib/mswin/postit.nsi
Normal file
114
contrib/mswin/postit.nsi
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
Unicode true
|
||||
RequestExecutionLevel user
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "x64.nsh"
|
||||
|
||||
!define APPNAME "PostIt.Desktop"
|
||||
!define APP_EXE "PostIt.Desktop.exe"
|
||||
!define PUBLISHER "Yavsc"
|
||||
!define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
|
||||
!define PROTOCOL_KEY "Software\Classes\postit"
|
||||
|
||||
!ifndef APP_VERSION
|
||||
!error "Define APP_VERSION with /DAPP_VERSION=... (Windows) or -DAPP_VERSION=... (Linux)."
|
||||
!endif
|
||||
!ifndef PUBLISH_DIR
|
||||
!define PUBLISH_DIR "..\..\src\PostIt\PostIt.Desktop\bin\Release\net10.0\win-x64\publish"
|
||||
!endif
|
||||
!ifndef OUTPUT_FILE
|
||||
!define OUTPUT_FILE "PostIt-${APP_VERSION}-win-x64-Setup.exe"
|
||||
!endif
|
||||
!if ! /FileExists "${PUBLISH_DIR}\${APP_EXE}"
|
||||
!error "Publish PostIt.Desktop for win-x64 before compiling the installer (see INSTALL.md)."
|
||||
!endif
|
||||
|
||||
Name "${APPNAME}"
|
||||
OutFile "${OUTPUT_FILE}"
|
||||
InstallDir "$LOCALAPPDATA\${PUBLISHER}\${APPNAME}"
|
||||
InstallDirRegKey HKCU "${UNINSTALL_KEY}" "InstallLocation"
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_ICON "..\..\src\PostIt\PostIt\Assets\yavsc-logo.ico"
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
!insertmacro MUI_LANGUAGE "French"
|
||||
|
||||
Function .onInit
|
||||
SetShellVarContext current
|
||||
${IfNot} ${RunningX64}
|
||||
MessageBox MB_OK|MB_ICONSTOP "Cette version de PostIt.Desktop requiert Windows 64 bits." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.onInit
|
||||
SetShellVarContext current
|
||||
FunctionEnd
|
||||
|
||||
Section "PostIt.Desktop"
|
||||
; Keep the recursive payload separate from user data and the uninstaller.
|
||||
ClearErrors
|
||||
SetOutPath "$INSTDIR\app"
|
||||
${If} ${Errors}
|
||||
MessageBox MB_OK|MB_ICONSTOP "Impossible de creer le dossier d'installation. Verifiez les droits d'ecriture, puis relancez l'installation." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
File /r "${PUBLISH_DIR}\*"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
${If} ${Errors}
|
||||
MessageBox MB_OK|MB_ICONSTOP "Installation impossible. Fermez PostIt.Desktop et verifiez les droits d'ecriture et l'espace disque, puis relancez l'installation." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
ClearErrors
|
||||
CreateDirectory "$SMPROGRAMS\${PUBLISHER}"
|
||||
CreateShortcut "$SMPROGRAMS\${PUBLISHER}\${APPNAME}.lnk" "$INSTDIR\app\${APP_EXE}"
|
||||
|
||||
WriteRegStr HKCU "${PROTOCOL_KEY}" "" "URL:PostIt"
|
||||
WriteRegStr HKCU "${PROTOCOL_KEY}" "URL Protocol" ""
|
||||
WriteRegStr HKCU "${PROTOCOL_KEY}\DefaultIcon" "" '"$INSTDIR\app\${APP_EXE}",0'
|
||||
WriteRegStr HKCU "${PROTOCOL_KEY}\shell\open\command" "" '"$INSTDIR\app\${APP_EXE}" "%1"'
|
||||
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayName" "${APPNAME}"
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayVersion" "${APP_VERSION}"
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "Publisher" "${PUBLISHER}"
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayIcon" '"$INSTDIR\app\${APP_EXE}",0'
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
|
||||
WriteRegStr HKCU "${UNINSTALL_KEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoModify" 1
|
||||
WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoRepair" 1
|
||||
${If} ${Errors}
|
||||
MessageBox MB_OK|MB_ICONSTOP "Impossible de creer les raccourcis ou les entrees du registre utilisateur. Relancez l'installation." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
SetOutPath "$TEMP"
|
||||
ClearErrors
|
||||
RMDir /r "$INSTDIR\app"
|
||||
${If} ${Errors}
|
||||
MessageBox MB_OK|MB_ICONSTOP "Des fichiers n'ont pas pu etre supprimes. Fermez PostIt.Desktop, puis relancez la desinstallation." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
; Do not remove a protocol handler subsequently registered by another install.
|
||||
ReadRegStr $0 HKCU "${PROTOCOL_KEY}\shell\open\command" ""
|
||||
${If} $0 == '"$INSTDIR\app\${APP_EXE}" "%1"'
|
||||
DeleteRegKey HKCU "${PROTOCOL_KEY}"
|
||||
${EndIf}
|
||||
Delete "$SMPROGRAMS\${PUBLISHER}\${APPNAME}.lnk"
|
||||
RMDir "$SMPROGRAMS\${PUBLISHER}"
|
||||
DeleteRegKey HKCU "${UNINSTALL_KEY}"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
SectionEnd
|
||||
|
|
@ -6,7 +6,17 @@
|
|||
"https://preprod-blogs.$DOMAIN"
|
||||
],
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp"
|
||||
"TempDir": "temp",
|
||||
"Owner": {
|
||||
"Name": "Paul Schneider",
|
||||
"EMail": "paul@pschneider.fr"
|
||||
}
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "mercure.pschneider.fr",
|
||||
"Port": 465,
|
||||
"UserName": "paul",
|
||||
"Password": "$SMTP_PASSWORD"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
|
|||
| [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter |
|
||||
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
|
||||
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
|
||||
| [architecture/notifications.md](architecture/notifications.md) | Notifications persistées, messages Web et événements temps réel côté serveur, Web et PostIt |
|
||||
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
|
||||
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
|
||||
| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests |
|
||||
|
|
|
|||
229
doc/architecture/notifications.md
Normal file
229
doc/architecture/notifications.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# Notifications Yavsc : serveur, Web et PostIt
|
||||
|
||||
Ce document décrit les mécanismes de notification actuellement présents
|
||||
dans le dépôt. Le terme « notification » recouvre plusieurs flux distincts :
|
||||
|
||||
1. les **annonces persistées** en base, affichées dans le site Web et sur
|
||||
l'accueil de PostIt ;
|
||||
2. les **messages temporaires de l'interface Web**, attachés à une réponse
|
||||
MVC par `ViewBag.Notify` ;
|
||||
3. les **événements temps réel** du chat et les événements métier diffusés par
|
||||
SignalR.
|
||||
|
||||
Ces flux n'ont ni le même stockage, ni le même cycle de vie, ni les mêmes
|
||||
clients.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
| Flux | Serveur / transport | Web | PostIt |
|
||||
|---|---|---|---|
|
||||
| Annonce persistée | Entité `Notification` en base, API HTTP | Affichée dans le layout et masquable | Liste sur `HomePage`, chargée par HTTP et masquable |
|
||||
| Message d'interface | Liste de portée requête dans `ViewBag.Notify` | Affichée comme alerte dans le layout | Non consommé |
|
||||
| Événement de chat | Hub SignalR authentifié à `/chathub` | Le site Web n'en consomme pas dans ses vues MVC | `notifyUser` et `notifyRoom` alimentent les journaux du chat |
|
||||
| Événement métier `push` | SignalR vers les connexions actives, plus e-mail | Pas de consommateur identifié dans les vues Web | Pas de gestionnaire `push` dans le client PostIt actuel |
|
||||
|
||||
Les notifications persistées sont donc différentes d'une notification système
|
||||
native (notification du navigateur ou du système d'exploitation). Le client
|
||||
PostIt affiche une liste dans sa page d'accueil ; il ne demande pas de
|
||||
permission de notification native.
|
||||
|
||||
## 1. Annonces persistées
|
||||
|
||||
### Modèle et stockage
|
||||
|
||||
L'entité partagée [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs)
|
||||
est enregistrée dans la table `Notification` via `ApplicationDbContext`.
|
||||
Elle contient :
|
||||
|
||||
- `Id`, identifiant généré ;
|
||||
- `title` et `body`, obligatoires, limités respectivement à 1 024 et 512
|
||||
caractères ;
|
||||
- `icon`, `sound`, `tag`, `color`, propriétés optionnelles ;
|
||||
- `click_action`, obligatoire, utilisé comme libellé d'action dans les
|
||||
interfaces ;
|
||||
- `Target`, chaîne de ciblage. Les valeurs documentées par le modèle sont
|
||||
`null` (tout le monde), `user/{UserId}`, `pub/{topic}` et
|
||||
`administration`.
|
||||
|
||||
Le masquage d'une annonce pour un utilisateur connecté est stocké dans
|
||||
`DismissClicked`, avec une clé composée de l'identifiant utilisateur et de
|
||||
l'identifiant de notification. Le même avis peut ainsi être masqué
|
||||
indépendamment par chaque utilisateur.
|
||||
|
||||
### API
|
||||
|
||||
[`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs)
|
||||
expose les deux routes GET suivantes, sous le préfixe `/api/v1` :
|
||||
|
||||
| Route | Accès | Résultat |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/notifications` | Anonyme ou authentifié | Notifications visibles à l'appelant, non masquées |
|
||||
| `GET /api/v1/notifications/me` | Anonyme ou authentifié | Même action et mêmes règles que la route sans suffixe |
|
||||
|
||||
La réponse est une liste JSON triée par `Id` décroissant. L'API exclut les
|
||||
notifications masquées par l'utilisateur authentifié, puis applique les
|
||||
règles d'audience : cible vide, cible `pub/…`, cible correspondant à
|
||||
`user/{identifiant courant}`, ou cible `administration` pour un administrateur.
|
||||
Une requête anonyme n'a ni identifiant utilisateur ni rôle administrateur.
|
||||
|
||||
[`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs)
|
||||
est réservé aux utilisateurs authentifiés :
|
||||
|
||||
| Route | Effet |
|
||||
|---|---|
|
||||
| `GET /api/v1/dismiss` | Liste les identifiants masqués par l'utilisateur |
|
||||
| `POST /api/v1/dismiss/click/{notificationId}` | Masque l'avis pour l'utilisateur ; l'opération est idempotente |
|
||||
| `DELETE /api/v1/dismiss/click/{notificationId}` | Rétablit l'avis s'il était masqué ; renvoie `404` sinon |
|
||||
|
||||
Le POST renvoie `404` si l'identifiant de notification n'existe pas. Le modèle
|
||||
de réponse est [`DismissedNotification`](../../src/Yavsc.Abstract/Messaging/DismissedNotification.cs).
|
||||
|
||||
### Création et notifications métier
|
||||
|
||||
Le contrôleur MVC
|
||||
[`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs)
|
||||
permet la gestion CRUD des enregistrements de notification.
|
||||
|
||||
Le service [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs)
|
||||
crée aussi une notification persistée, ciblée sur `user/{id}`, pour les
|
||||
événements `RdvQueryEvent` et `ClientEstimateSignedEvent`. L'enregistrement
|
||||
est effectué avant l'envoi d'e-mail. Le même service tente ensuite d'envoyer
|
||||
un e-mail si l'adresse est disponible et confirmée, puis diffuse l'événement
|
||||
`push` aux connexions SignalR actives de l'utilisateur.
|
||||
|
||||
Cette diffusion `push` est conditionnée par la présence de connexions actives ;
|
||||
l'avis stocké en base reste, lui, disponible lors d'une récupération ultérieure
|
||||
par l'API.
|
||||
|
||||
## 2. Site Web
|
||||
|
||||
### Annonces persistées sur l'accueil
|
||||
|
||||
[`HomeController.Index`](../../src/Yavsc.Org/Controllers/HomeController.cs)
|
||||
charge les notifications non masquées et les place dans `ViewBag.Notify`.
|
||||
[`_Layout.cshtml`](../../src/Yavsc.Org/Views/Shared/_Layout.cshtml) les rend
|
||||
comme des alertes Bootstrap avec icône, titre, corps et action. Le titre et le
|
||||
corps passent par les composants Markdown du site.
|
||||
|
||||
Le clic sur l'action appelle `POST /Home/DismissNotification/{id}` via
|
||||
[`site.js`](../../src/Yavsc.Org/wwwroot/js/site.js) :
|
||||
|
||||
- pour un utilisateur connecté, le masquage est enregistré dans
|
||||
`DismissClicked` ;
|
||||
- pour un visiteur anonyme, les identifiants sont conservés dans la session
|
||||
Web (`clicked`).
|
||||
|
||||
La route renvoie `404` si l'avis n'existe pas et `204` après le masquage.
|
||||
Comme l'API, l'accueil Web filtre `Target` : les cibles `user/{id}` ne sont
|
||||
visibles qu'à l'utilisateur correspondant, et la cible `administration`
|
||||
uniquement aux administrateurs. Un visiteur anonyme ne reçoit que les
|
||||
notifications publiques (`Target` vide ou préfixe `pub/`).
|
||||
|
||||
### Messages temporaires par requête
|
||||
|
||||
Les helpers [`NotifyInfo`, `NotifyWarning` et `Notify`](../../src/Yavsc.Org/Helpers/ControllerHelpers.cs)
|
||||
ajoutent des objets à `ViewBag.Notify`. Ce mécanisme n'écrit pas en base :
|
||||
il ne vit que dans la réponse MVC qui rend le layout. Les méthodes
|
||||
`NotifyInfo` et `NotifyWarning` partagent actuellement le même rendu générique
|
||||
`alert-info`.
|
||||
|
||||
## 3. PostIt
|
||||
|
||||
[`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs)
|
||||
construit les routes à partir de la base d'API de l'hôte métier configuré.
|
||||
Il fournit les méthodes de lecture publique et authentifiée, ainsi que les
|
||||
opérations de lecture, masquage et restauration.
|
||||
|
||||
Sur [`HomePage`](../../src/PostIt/PostIt/Views/Layout/HomePage.axaml),
|
||||
[`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs)
|
||||
charge la liste :
|
||||
|
||||
- sans session valide, il appelle la route publique
|
||||
`GET /api/v1/notifications` ;
|
||||
- avec une session valide, il appelle
|
||||
`GET /api/v1/notifications/me` avec le client HTTP authentifié.
|
||||
|
||||
Le chargement est lancé à l'ouverture de la page et relancé lorsque l'état de
|
||||
connexion change. Un compteur de version ignore les réponses obsolètes si un
|
||||
chargement plus récent a déjà commencé. L'interface distingue l'état de
|
||||
chargement, la liste vide et l'erreur de chargement ; l'utilisateur peut aussi
|
||||
actualiser manuellement.
|
||||
|
||||
Le bouton d'une notification utilise `click_action` comme libellé et masque
|
||||
l'élément. En session authentifiée, PostIt appelle
|
||||
`POST /api/v1/dismiss/click/{id}` avant de retirer l'élément de la liste. Si
|
||||
l'appel échoue, l'élément reste affiché et une erreur est présentée. Sans
|
||||
session, l'élément est retiré de la liste locale, sans persistance du
|
||||
masquage côté serveur.
|
||||
|
||||
## 4. Événements temps réel
|
||||
|
||||
### Hub du chat
|
||||
|
||||
Le hub [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs) est publié par
|
||||
Yavsc.Api à la racine sous `/chathub` (et non sous `/api/v1`) et porte
|
||||
`[Authorize]`. Il s'appuie sur SignalR et sur `IConnexionManager` pour suivre
|
||||
les utilisateurs et les salons.
|
||||
|
||||
Les événements de notification émis par le hub comprennent notamment :
|
||||
|
||||
| Événement SignalR | Données | Utilisation PostIt |
|
||||
|---|---|---|
|
||||
| `notifyUser` | `type`, identifiant ou nom cible, message | Ajout au journal système |
|
||||
| `notifyRoom` | `type`, salon, utilisateur ou détail | Ajout au journal du salon ; met à jour la liste d'utilisateurs sur `UserJoin` |
|
||||
| `joint` | `ChatRoomInfo` | Initialise la vue du salon rejoint |
|
||||
| `ReceiveMessage` | message avec nom, salon et corps | Ajoute le message à l'historique du salon |
|
||||
| `notifyUserInRoom` | `type`, salon, message | Pas de gestionnaire correspondant dans `ChatViewModel` actuellement |
|
||||
|
||||
Les constantes de types du chat sont déclarées dans
|
||||
[`NotificationTypes`](../../src/Yavsc.Abstract/Messaging/NotificationTypes.cs).
|
||||
Les messages `notifyUser` et `notifyRoom` ne sont pas enregistrés en base :
|
||||
ils décrivent l'état temps réel de connexion, de salon ou les erreurs de
|
||||
conversation.
|
||||
|
||||
Le [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs)
|
||||
ouvre le hub avec le jeton d'accès PostIt, tente un renouvellement silencieux
|
||||
du jeton, et active la reconnexion automatique. Après reconnexion, il rejoint
|
||||
à nouveau le salon sélectionné, car la connexion SignalR précédente et ses
|
||||
groupes ne sont plus actifs. Les callbacks mettent à jour les collections sur
|
||||
le thread UI d'Avalonia.
|
||||
|
||||
### Événement métier `push`
|
||||
|
||||
En plus des événements du chat, `YavscMessageSender` utilise le même contexte
|
||||
SignalR pour émettre `push` avec le topic et une charge utile sérialisée. Ce
|
||||
nom d'événement est distinct de `notifyUser` et `notifyRoom`. Aucun
|
||||
gestionnaire `push` n'est présent dans le client PostIt actuel : les annonces
|
||||
métier persistées sont visibles dans PostIt lors du prochain chargement ou
|
||||
rafraîchissement HTTP.
|
||||
|
||||
## 5. Points d'attention pour les évolutions
|
||||
|
||||
- Conserver les règles d'audience alignées entre l'API et le site Web ; en
|
||||
particulier, appliquer un filtrage `Target` côté Web avant d'exposer les
|
||||
avis dans le layout.
|
||||
- Ne pas confondre masquage utilisateur (`DismissClicked`) et suppression
|
||||
globale de la notification : le masquage API est par utilisateur.
|
||||
- Les sessions anonymes du site Web et PostIt n'ont pas le même comportement :
|
||||
le site Web conserve le masquage en session, tandis que PostIt ne le garde
|
||||
que dans la liste affichée jusqu'au prochain rechargement.
|
||||
- Ajouter un nouveau type de cible ou un nouvel événement temps réel implique
|
||||
d'examiner séparément son filtrage, son transport et les clients qui
|
||||
l'écoutent.
|
||||
- Les références à GCM et le nom historique de `YavscMessageSender` ne
|
||||
signifient pas que PostIt reçoit une notification native hors ligne : le
|
||||
chemin observé ici est l'e-mail, l'événement SignalR temps réel et la
|
||||
notification persistée récupérée par HTTP.
|
||||
|
||||
## Références d'implémentation
|
||||
|
||||
- [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs)
|
||||
- [`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs)
|
||||
- [`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs)
|
||||
- [`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs)
|
||||
- [`HomeController`](../../src/Yavsc.Org/Controllers/HomeController.cs)
|
||||
- [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs)
|
||||
- [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs)
|
||||
- [`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs)
|
||||
- [`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs)
|
||||
- [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs)
|
||||
|
|
@ -40,6 +40,158 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop).
|
|||
Toute la logique (VM, services, navigation, settings, OIDC) est
|
||||
dans le code-base partagé.
|
||||
|
||||
## Reprise Android
|
||||
|
||||
`IActivityApplicationLifetime.MainViewFactory` crée une nouvelle `MainView`
|
||||
pour chaque activité Android. Avant de remplacer la racine, PostIt relève
|
||||
les types et les modèles de vue des pages de la pile précédente, puis
|
||||
recrée ces pages dans le même ordre, sans animation. Les contrôles de
|
||||
l'ancienne activité ne sont pas réutilisés ; les modèles de vue conservent
|
||||
les sélections et brouillons en mémoire. Une nouvelle racine sans historique
|
||||
reçoit la page d'accueil, même si l'initialisation de session a déjà eu lieu.
|
||||
Le rafraîchissement silencieux de session reste unique par processus.
|
||||
Cette restauration couvre la recréation d'activité dans le même processus,
|
||||
pas la destruction du processus par Android.
|
||||
|
||||
## Administration et profil personnel
|
||||
|
||||
L'accueil présente un seul accès **Administration**, visible et exécutable
|
||||
uniquement pour le rôle exact `Administrator`. `SessionStatusViewModel.RefreshAsync`
|
||||
lit les rôles dans `GET account/me` après le rafraîchissement de session ;
|
||||
une erreur de lecture masque l'accès et affiche une erreur dans la bannière.
|
||||
La déconnexion invalide aussi les vérifications de droits encore en vol.
|
||||
|
||||
`AdministrationPage` regroupe sept onglets : utilisateurs, usages globaux,
|
||||
rôles, clients OIDC, scopes API, courriels et données. Les en-têtes se
|
||||
répartissent sur plusieurs lignes sur les écrans étroits.
|
||||
Le détail d'un utilisateur associe son usage par `UserId`, sans dépendre
|
||||
du nom affiché. Les erreurs ne déclenchent pas de repli sur l'usage personnel.
|
||||
Le backend réserve aussi les listes aux administrateurs, sous la
|
||||
politique `AdministratorOnly`. Le rôle `Moderator` seul n'ouvre plus
|
||||
l'administration de PostIt. L'autorisation serveur reste indispensable,
|
||||
indépendamment de la visibilité du bouton dans PostIt.
|
||||
|
||||
**Mon profil** réutilise la page de gestion du compte existante et les
|
||||
endpoints `account/me` pour lire et enregistrer les informations personnelles.
|
||||
Elle affiche aussi `resource-usage/me`, sans appeler la liste des usages
|
||||
globaux. Une indisponibilité de l'usage n'empêche pas la gestion du profil.
|
||||
|
||||
### Outils d'administration du site
|
||||
|
||||
Les outils portés correspondent aux contrôleurs de
|
||||
`Yavsc.Org/Controllers/Administration` ; le site web existant est conservé.
|
||||
Les nouvelles routes vivent dans Yavsc.Api sous `api/v1/administration`.
|
||||
Les données passent par des records dédiés de Yavsc.Abstract et par
|
||||
`AdministrationApiClient`, jamais par les entités EF.
|
||||
|
||||
| Routes | Fonction |
|
||||
| --- | --- |
|
||||
| `summary`, `roles`, `roles/ensure` | Statistiques, informations du runtime, rôles standards |
|
||||
| `roles/{id}/members[/{userId}]` | Membres, inscription et exclusion |
|
||||
| `clients[/{id}]` | Paramètres scalaires des clients OAuth/OIDC |
|
||||
| `clients/{id}/collections/{collection}[/{rowId}]` | Redirect URIs, post-logout URIs, scopes, grant types, CORS, restrictions IdP, claims et propriétés |
|
||||
| `clients/{id}/secrets[/regenerate]`, `clients/{id}/secrets/{secretId}` | Ajout, rotation et suppression des secrets |
|
||||
| `scopes[/{id}]` | Création, consultation, modification et suppression des scopes API |
|
||||
| `mailing-criteria`, `mailing-templates[/{id}]` | Critères de diffusion et modèles de courriels |
|
||||
| `transfer` | Export par GET, import transactionnel par POST |
|
||||
|
||||
Les formulaires de clients, scopes et modèles sont construits à partir
|
||||
des propriétés de leurs DTO typés, afin de conserver tous les champs de
|
||||
l'interface web. Les booléens et énumérations disposent de contrôles dédiés ;
|
||||
les valeurs numériques et annotations de validation sont vérifiées avant
|
||||
l'envoi puis à nouveau côté serveur.
|
||||
|
||||
Un nouveau client démarre avec `authorization_code`, PKCE et les scopes
|
||||
`openid` et `profile`. Les autres types d'autorisation restent configurables
|
||||
dans ses collections. Une rotation remplace tous les secrets précédents et
|
||||
expire par défaut après 90 jours. Le serveur conserve uniquement le SHA-256
|
||||
du secret partagé ; les lectures ne rendent que les métadonnées.
|
||||
PostIt efface la valeur nouvellement créée au changement de client, à la
|
||||
prochaine opération et au détachement de la page. Une réponse de création
|
||||
de secret reçue après déconnexion est ignorée ; un rafraîchissement sans
|
||||
droits efface les données d'administration précédemment chargées. Supprimer un objet,
|
||||
exclure un membre, remplacer les secrets ou importer demande une confirmation
|
||||
explicite. Le serveur refuse l'exclusion du dernier administrateur.
|
||||
|
||||
Le transfert JSON version 1 comprend les informations métier des comptes
|
||||
(identifiant, nom, e-mail, nom complet, avatar, calendrier et limites de
|
||||
stockage), les billets (contenu, auteur, publication, cercles et étiquettes)
|
||||
et les paramètres des activités. Ce n'est pas une sauvegarde complète de
|
||||
la base : fichiers, sessions, mots de passe, jetons, rôles, moyens de paiement,
|
||||
adresses et historique d'audit ne sont pas transférés. L'e-mail d'un compte
|
||||
existant, clé alternative du modèle actuel, ne peut pas être changé par import.
|
||||
Les nouveaux comptes n'ont ni mot de passe ni e-mail confirmé.
|
||||
|
||||
Les identifiants des billets sont préservés et la séquence PostgreSQL est
|
||||
réajustée après l'import. Tous les auteurs et cercles doivent exister et
|
||||
les parents d'activités doivent former un graphe sans cycle. Une erreur
|
||||
annule l'ensemble de l'import ; aucun repli n'élargit les permissions d'un
|
||||
billet. Les fichiers importés et requêtes sont limités à 16 Mio ; les gros
|
||||
exports doivent être répartis en plusieurs transferts de même schéma.
|
||||
Si plusieurs étiquettes portent déjà le même nom, l'import réutilise celle
|
||||
ayant le plus petit identifiant sans supprimer les autres.
|
||||
|
||||
L'initialisation du premier administrateur reste le mécanisme web existant,
|
||||
et n'est pas proposée aux utilisateurs non administrateurs par PostIt.
|
||||
En développement, `/openapi/administration.json` décrit uniquement ces outils,
|
||||
avec accès `AdministratorOnly`. Les requêtes de référence sont dans
|
||||
[Administration.http](../../src/Yavsc.Api/Administration.http) ; l'adresse
|
||||
locale d'exemple est celle du serveur de tests, à adapter au déploiement.
|
||||
|
||||
## Lecture des billets et fichiers utilisateur
|
||||
|
||||
La page des blogs affiche par défaut une vue de lecture : titre, login
|
||||
de l'auteur, date de modification et contenu Markdown dans une zone
|
||||
défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et
|
||||
**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche
|
||||
le contenu enregistré sans effacer les modifications non enregistrées ;
|
||||
un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé.
|
||||
|
||||
La base des ressources est dérivée de `Settings.Authentication.Authority`
|
||||
(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` :
|
||||
`https://<hôte Org>/files/<Author.UserName>/`. Le repli sur `AuthorId`
|
||||
conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé.
|
||||
Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ;
|
||||
un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org.
|
||||
Les liens absolus ne sont pas réécrits. Les images Markdown ``
|
||||
sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
|
||||
sans accès aux fichiers locaux du poste.
|
||||
Le résolveur charge chaque image dans un flux mémoire repositionnable
|
||||
initialement à la position zéro, nécessaire au décodage du lecteur Markdown.
|
||||
|
||||
Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible
|
||||
Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur
|
||||
conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur
|
||||
avant d'émettre l'événement de lien et ne permet donc pas ce routage.
|
||||
|
||||
Au clic sur un fichier sous `/files/<login>/...` de la même origine Org
|
||||
(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec
|
||||
`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse
|
||||
`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une
|
||||
extension connue sert de repli uniquement en l'absence de type exploitable
|
||||
(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont
|
||||
affichées dans la barre de statut, sans les masquer par une déduction MIME.
|
||||
|
||||
Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
|
||||
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
|
||||
une action d'ouverture externe. Le lecteur et ses ressources natives sont
|
||||
arrêtés et libérés à la sortie de la page. La lecture utilise directement
|
||||
`new LibVLC()`, `new MediaPlayer(libVlc)` et `Play(media)`, sans options
|
||||
spécifiques à Linux ni détection VAAPI/VDPAU. Le média est libéré après
|
||||
`Play` comme dans l'exemple officiel ; le lecteur conserve sa référence native.
|
||||
**LibVLC doit être installé**
|
||||
sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
|
||||
bibliothèques natives adaptées sont nécessaires sous Windows/macOS).
|
||||
Une absence de LibVLC ou une erreur de décodage est signalée dans la page.
|
||||
Le navigateur ne s'ouvre que sur demande explicite, jamais en repli automatique.
|
||||
Après la navigation, `TryUpdateNativeControlPosition()` synchronise la surface
|
||||
native à la priorité `Loaded`, une fois la mise en page effectuée :
|
||||
les transformations de transition ne déclenchent pas de changement de `Bounds`.
|
||||
Le rappel est ignoré si le lecteur a été libéré ou remplacé entre-temps.
|
||||
Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible,
|
||||
les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
|
||||
(images liées, PDF, téléchargements) et les liens externes.
|
||||
|
||||
## ViewLocator custo
|
||||
|
||||
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
|
||||
|
|
@ -88,6 +240,58 @@ faut *deux* enregistrements : la page en `AddTransient` (ou
|
|||
de `ViewLocator.Build`. Si l'un manque, l'app affiche
|
||||
"No view for X" sans crash.
|
||||
|
||||
## Podcasts : capture, direct et replay
|
||||
|
||||
`PodcastsPageViewModel` est accessible depuis l'accueil Desktop Windows/Linux.
|
||||
`CameraStreamer` detecte les cameras DirectShow/V4L2 et les microphones
|
||||
(DirectShow sous Windows, source PulseAudio par defaut sous Linux).
|
||||
`PodcastBroadcaster` utilise uniquement LibVLC : encodage H.264/AAC, sortie
|
||||
`livehttp`, segments MPEG-TS d'environ deux secondes. Un segment n'est
|
||||
envoye que lorsque l'index local LibVLC le declare complet.
|
||||
|
||||
`PodcastApiClient` reutilise le transport bearer et son rafraichissement
|
||||
des jetons. Les PUT sont numerotes a partir de zero ; une nouvelle tentative
|
||||
doit avoir exactement les memes octets et la meme duree. Le client supprime
|
||||
un segment local seulement apres acquittement, et envoie le dernier segment
|
||||
ferme par LibVLC avant de terminer le direct. La navigation et la deconnexion
|
||||
arretent la camera. Les erreurs restent visibles ; les fichiers non envoyes
|
||||
restent disponibles localement pour recuperation manuelle.
|
||||
|
||||
`ILiveProcessor` est un singleton dans Yavsc.Api. Il stocke les metadonnees
|
||||
et empreintes SHA-256 dans un index JSON remplace atomiquement, avec des
|
||||
segments immuables accessibles uniquement via les endpoints authentifies.
|
||||
Deux receptions au plus sont tamponnees en parallele ; les validations de
|
||||
sequence, de proprietaire et de quota sont serialisees avant commit.
|
||||
Un index ne reference jamais un segment en cours d'ecriture.
|
||||
|
||||
| Route sous `api/v1/podcasts` | Role |
|
||||
| --- | --- |
|
||||
| `GET /`, `POST /` | Catalogue ou creation d'une emission |
|
||||
| `GET /{id}` | Metadonnees et segments ordonnes |
|
||||
| `PUT /{id}/segments/{sequence}?duration=...` | Ingestion `video/mp2t`, auteur uniquement |
|
||||
| `POST /{id}/complete` | Finalisation idempotente du replay |
|
||||
| `GET /{id}/index.m3u8` | Playlist HLS EVENT, ENDLIST apres fin/interruption |
|
||||
| `GET /{id}/segments/{sequence}.ts` | Segment engage, lecture authentifiee |
|
||||
| `DELETE /{id}` | Suppression d'une emission terminee, auteur uniquement |
|
||||
|
||||
Toutes les routes requierent la politique `ApiScope`. La lecture est ouverte
|
||||
a tous les utilisateurs authentifies avec ce scope, pas seulement a l'auteur.
|
||||
`PodcastMediaInput` alimente LibVLC en octets MPEG-TS via les requetes
|
||||
authentifiees existantes, sans transmettre de jeton dans une URL ou un
|
||||
processus externe. Les rappels natifs ne laissent echapper aucune exception :
|
||||
l'erreur est remontee a la page de lecture.
|
||||
|
||||
Un direct inactif devient `Interrupted` lors de la prochaine lecture de
|
||||
l'index ; au redemarrage, tous les anciens directs sont interrompus.
|
||||
Les segments engages constituent le replay, sans transcodage serveur.
|
||||
Le stockage exige un seul processus par racine et les quotas podcast sont
|
||||
independants de ceux des fichiers utilisateur. Voir
|
||||
[INSTALL.md](../../INSTALL.md#podcast-live-broadcasts-and-replays) pour
|
||||
les limites et le deploiement ; [Podcast.http](../../src/Yavsc.Api/Podcast.http)
|
||||
pour les requetes.
|
||||
Le document OpenAPI dedie est `/openapi/podcasts.json`, accessible en
|
||||
developpement aux administrateurs comme les autres documents de l'API.
|
||||
|
||||
## Composition root (`App.axaml.cs`)
|
||||
|
||||
`App.OnFrameworkInitializationCompleted` est le seul endroit où
|
||||
|
|
@ -133,6 +337,20 @@ et les commandes restent visibles, tandis que la liste des billets et
|
|||
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
|
||||
de modifier cette répartition. La liste et l'article défilent séparément.
|
||||
|
||||
Lorsque la largeur de la fenêtre atteint ou dépasse sa hauteur, le lecteur
|
||||
ou l'éditeur passe à gauche (deux tiers de la largeur par défaut), et
|
||||
l'en-tête, la recherche, les commandes et la liste passent à droite.
|
||||
Le séparateur devient vertical. En portrait, la disposition verticale
|
||||
revient sans recréer les contrôles ni perdre le brouillon.
|
||||
Le seuil utilise les dimensions clientes de la fenêtre, indépendamment
|
||||
de la hauteur occupée par la navigation et la bannière de session.
|
||||
|
||||
La page applique `NavigationPage.BarHeightOverride` à 36 en mode large ;
|
||||
le `NavRoot` de `MainView` conserve ainsi le bouton Retour, sans changer
|
||||
les autres pages. En portrait, la hauteur de navigation par défaut revient.
|
||||
Les commandes ont un contexte de données explicite pour conserver leurs
|
||||
liaisons, y compris lorsqu'elles passent dans le menu de débordement.
|
||||
|
||||
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
|
||||
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
|
||||
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
|
||||
|
|
|
|||
299
doc/invoicing.md
Normal file
299
doc/invoicing.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# Facturation et paiement après devis signé
|
||||
|
||||
## Objectif
|
||||
|
||||
Ce document fixe le plan métier et technique pour le flux qui suit la validation du devis :
|
||||
|
||||
- le devis est signé par les deux parties,
|
||||
- le prestataire déclenche la création d'une facture non acquittée,
|
||||
- la facture contient la ligne associée au mode de paiement choisi par le prestataire,
|
||||
- le client règle le montant partiel ou total,
|
||||
- la facture reste non acquittée tant que le total n'est pas intégralement payé.
|
||||
|
||||
## Règle métier de base
|
||||
|
||||
1. Le devis n'est considéré comme valide que lorsqu'il est signé par le fournisseur et par le client.
|
||||
2. Une facture ne peut être générée qu'après cette double validation.
|
||||
3. Le type de contrat choisi dans les settings du prestataire détermine le mode de règlement :
|
||||
- arrhes,
|
||||
- avance,
|
||||
- ou autre mode de facturation supporté par le système.
|
||||
4. Le montant payé partiellement est enregistré comme paiement partiel, sans transformer la facture en facture entièrement payée.
|
||||
5. La facture est marquée comme acquittée uniquement lorsque le montant total est effectivement réglé.
|
||||
6. L'addition / le relevé de paiement doit mentionner le paiement effectué, sans prétendre que la facture est déjà entièrement acquittée.
|
||||
|
||||
## Flux attendu
|
||||
|
||||
### 1. Devis signé par les deux parties
|
||||
|
||||
Le flux contractuel suit la séquence :
|
||||
|
||||
- commande / demande,
|
||||
- devis préparé,
|
||||
- validation prestataire,
|
||||
- validation client,
|
||||
- double signature persistée.
|
||||
|
||||
Une fois cette étape passée, le devis est un contrat exploitable pour la facture.
|
||||
|
||||
### 2. Génération de la facture non acquittée
|
||||
|
||||
Le prestataire déclenche la création de la facture depuis le contexte du devis validé.
|
||||
|
||||
Le système doit alors :
|
||||
|
||||
- identifier le devis validé,
|
||||
- récupérer le client et le prestataire,
|
||||
- sélectionner le mode de contrat défini dans les settings du prestataire,
|
||||
- ajouter la ligne de frais correspondante à la facture,
|
||||
- créer la facture dans un état "non acquittée".
|
||||
|
||||
### 3. Paiement partiel
|
||||
|
||||
Le client peut payer en premier :
|
||||
|
||||
- l'avance,
|
||||
- les arrhes,
|
||||
- ou une tranche partielle.
|
||||
|
||||
Dans ce cas :
|
||||
|
||||
- le paiement est enregistré,
|
||||
- le montant payé est ajouté au total enregistré,
|
||||
- la facture reste "non acquittée" tant qu'elle n'a pas été réglée intégralement,
|
||||
- l'addition doit refléter la transaction effectuée sans annoncer un paiement total abusif.
|
||||
|
||||
### 4. Acquittement total
|
||||
|
||||
Lorsque le client a payé la somme totale due :
|
||||
|
||||
- le statut de la facture passe à "payée" / "acquittée",
|
||||
- la facture peut être considérée comme totalement soldée,
|
||||
- la génération de documents ou notifications finales peut s'appuyer sur cet état.
|
||||
|
||||
## Règles de codage à respecter
|
||||
|
||||
### Règle 1 — ne pas confondre paiement partiel et paiement total
|
||||
|
||||
Le modèle de paiement ne doit pas se limiter à un booléen "payé / non payé".
|
||||
|
||||
Le bon niveau de modélisation est :
|
||||
|
||||
- montant dû,
|
||||
- montant payé,
|
||||
- montant restant,
|
||||
- statut de paiement partiel / total,
|
||||
- état d'acquittement final.
|
||||
|
||||
### Règle 2 — la facture ne doit pas être "payée" trop tôt
|
||||
|
||||
Le système ne doit pas marquer la facture comme entièrement payée à la première transaction.
|
||||
|
||||
L'état métier doit être calculé de manière explicite :
|
||||
|
||||
- `montantPaye < montantTotal` => facture non acquittée,
|
||||
- `montantPaye >= montantTotal` => facture acquittée (reste à payer borné à zéro).
|
||||
|
||||
### Règle 3 — les arrhes / avances doivent rester cohérents avec le contexte contractuel
|
||||
|
||||
Le mode choisi par le prestataire (arrhes ou avance) doit être représenté de façon explicite dans le flux de génération de facture et dans l'addition.
|
||||
|
||||
Le comportement attendu est :
|
||||
|
||||
- la ligne de frais correspondante est ajoutée à la facture,
|
||||
- le paiement initial est comptabilisé correctement,
|
||||
- le solde restant reste visible et exploitable.
|
||||
|
||||
## Plan d'implémentation proposé
|
||||
|
||||
### Étape 1 — modéliser les règles de paiement
|
||||
|
||||
Implémentée sur le point d'entrée existant `IBillable` /
|
||||
`NominativeServiceCommand`, sans créer encore d'entité Facture distincte :
|
||||
|
||||
- `AmountDue` et `AmountPaid` sont persistés sur la commande ; le montant dû
|
||||
reste `null` tant que le total de facture n'a pas été établi.
|
||||
- `SetPaymentAmounts(amountDue, amountPaid)` définit un instantané validé
|
||||
des montants (décimaux non négatifs), côté métier uniquement. Les setters
|
||||
privés empêchent leur alimentation par les requêtes de modification de commande.
|
||||
- `PaymentSummary` expose les montants dû, payé et restant, ainsi que
|
||||
`Unpaid`, `PartiallyPaid` ou `Paid` ; il est absent si le montant dû est inconnu.
|
||||
- Le restant est borné à zéro en cas de trop-perçu, sans perdre le montant payé.
|
||||
L'acquittement est atteint si `AmountPaid >= AmountDue`. Une facture
|
||||
explicitement établie à zéro est soldée ; un montant inconnu ne l'est pas.
|
||||
- `GetIsAcquitted()` et le suffixe documentaire `-ack` utilisent ce calcul,
|
||||
jamais le seul statut PayPal `SUCCESS`.
|
||||
|
||||
La migration ajoute les montants sans reconstituer de paiements historiques :
|
||||
les anciennes commandes ont un dû inconnu et un payé nul. Un rapprochement
|
||||
explicite est nécessaire avant de les considérer comme acquittées.
|
||||
La génération depuis le devis et l'alimentation du payé sont désormais
|
||||
branchées par le service `InvoicingService` (étapes 2 et 4 ci-dessous).
|
||||
L'appel `SetPaymentAmounts` remplace un total cumulé ; il n'enregistre pas
|
||||
une transaction et ne doit pas être utilisé pour additionner un retour PayPal
|
||||
sans contrôle d'idempotence.
|
||||
|
||||
Objectifs couverts :
|
||||
|
||||
- identifier le point d'entrée métier de la facture,
|
||||
- ajouter un modèle de montant dû / montant payé / montant restant,
|
||||
- distinguer clairement l'état partiel du statut de paiement total,
|
||||
- éviter toute logique qui force un booléen `IsPaid = true` dès le premier paiement.
|
||||
|
||||
### Étape 2 — brancher la génération de facture sur le devis signé
|
||||
|
||||
Implémentée par `POST /api/v1/invoices/from-estimate/{estimateId}` :
|
||||
|
||||
- réservée au prestataire du devis (ou à un administrateur) ;
|
||||
- exige les deux dates de validation **et** les deux signatures persistées,
|
||||
non vides, associées aux bons signataires ; une date seule ne suffit pas ;
|
||||
- exige une commande existante, dont le client et le prestataire correspondent
|
||||
au devis ; les devis sans commande restent des devis, non des factures ;
|
||||
- copie les lignes du devis, sa devise EUR, son total et ses parties dans
|
||||
un instantané de facture sur la commande, daté côté serveur ;
|
||||
- accepte les remises mais refuse un total négatif, les quantités non positives,
|
||||
les prix infra-centime et les devis multidevises ;
|
||||
- initialise le payé à zéro, sans assimiler un ancien checkout à un encaissement ;
|
||||
- renvoie la même facture lors d'un nouvel appel pour le même devis, sans
|
||||
changer le total ou effacer les paiements ; une autre facture ou un solde
|
||||
historique préexistant sur la commande provoque un conflit de rapprochement.
|
||||
|
||||
`GET /api/v1/invoices/{commandId}` expose l'instantané et l'historique aux
|
||||
parties de la facture et aux administrateurs. Les statuts sont des chaînes
|
||||
`Unpaid`, `PartiallyPaid` et `Paid`. Les lignes utilisées par `GetBillItems()`
|
||||
sont celles de la facture figée, même si le devis est modifié ensuite.
|
||||
Les commandes facturées et leurs devis ne sont plus supprimables par les
|
||||
routes de commande concernées. Le changement de prestataire est bloqué sur
|
||||
les routes usuelles de modification ; l'instantané reste la référence des parties.
|
||||
|
||||
Cette création établit la facture métier et ses montants, **pas un PDF** :
|
||||
le moteur historique de rendu documentaire reste distinct. Ses routes de
|
||||
génération exigent désormais une facture établie.
|
||||
|
||||
### Étape 3 — intégrer le mode de contrat du prestataire
|
||||
|
||||
- lire le setting du prestataire qui indique le type de contrat,
|
||||
- associer ce mode à la ligne de frais ajoutée sur la facture,
|
||||
- garantir que la ligne peut être différenciée selon arrhes ou avance.
|
||||
|
||||
### Étape 4 — enregistrer les paiements sans surinterpréter le statut
|
||||
|
||||
Implémentée par deux sources d'encaissement, sans débit déclenché par ces routes :
|
||||
|
||||
| Route POST sous `/api/v1/invoices/{commandId}` | Source de vérité | Accès |
|
||||
| --- | --- | --- |
|
||||
| `/payments/offline` | Attestation du prestataire : référence, montant, devise, date de réception | Prestataire ou administrateur |
|
||||
| `/payments/paypal` | Réponse serveur PayPal `GetTransactionDetails` pour un identifiant de transaction | Parties ou administrateur |
|
||||
|
||||
Le reçu hors ligne doit être strictement positif, en centimes EUR, avec une
|
||||
référence non vide et une date non future. Il est attesté, pas vérifié auprès
|
||||
de la banque. La référence est unique par prestataire ; le client ne peut
|
||||
pas s'auto-déclarer acquitté.
|
||||
|
||||
Pour PayPal, seul l'identifiant est accepté du client. Le serveur vérifie :
|
||||
|
||||
- statut `Completed`, et non un simple `ACK=Success` ou un statut `Pending` ;
|
||||
- identifiant exact, référence de facture `TypeCommande/Id` (`INVNUM`),
|
||||
devise de la facture et compte marchand destinataire (`RECEIVERID`) ;
|
||||
- montant brut reçu strictement positif et date retournés par PayPal.
|
||||
|
||||
Le token de checkout historique n'est **pas** un identifiant de transaction.
|
||||
Le retour historique `ConfirmPayment` ne constitue toujours pas une preuve
|
||||
d'encaissement et n'alimente pas le solde. Cette intégration vérifie des
|
||||
transactions déjà encaissées ; elle n'ajoute ni capture, ni webhook, ni
|
||||
automatisation du retour navigateur. Le protocole NVP reprend le système
|
||||
Classic déjà utilisé par le dépôt ; il est déprécié par PayPal et réservé
|
||||
aux marchands existants compatibles.
|
||||
|
||||
Chaque encaissement conserve sa référence, son origine, son montant, sa
|
||||
devise, ses dates de réception/enregistrement et l'identifiant de l'acteur.
|
||||
La référence PayPal est unique globalement : une même transaction ne peut
|
||||
pas solder deux factures. Rejouer un reçu identique ne l'ajoute pas deux fois ;
|
||||
réutiliser sa référence avec d'autres détails renvoie `409`.
|
||||
|
||||
L'ajout au journal et la mise à jour du total payé sont sauvegardés dans la
|
||||
même transaction EF relationnelle. Un jeton de concurrence sur la commande
|
||||
empêche les pertes de mise à jour. En cas de conflit concurrent, la route
|
||||
renvoie `409` : rejouer **la même référence et le même corps** permet de
|
||||
réessayer sans double comptage. Un solde ne correspondant pas au journal
|
||||
requiert un rapprochement, sans réinitialisation silencieuse.
|
||||
|
||||
Le solde est calculé à partir des reçus cumulés. Un paiement partiel garde
|
||||
la facture non acquittée ; un trop-perçu est conservé avec un restant nul.
|
||||
Le texte de l'addition affiche payé, restant et statut.
|
||||
|
||||
#### Configuration et utilisation
|
||||
|
||||
Appliquer la migration `SignedEstimateInvoicing` avant déploiement.
|
||||
Elle ajoute l'instantané, le jeton de concurrence et le journal `InvoicePayments`,
|
||||
sans créer de facture ni de reçu pour les données historiques.
|
||||
|
||||
La vérification PayPal utilise la section `Authentication:PayPal` :
|
||||
`Mode` (`sandbox` ou `production`), `MerchantAccountId` et
|
||||
`MerchantAccountUserName`. Une entrée de `Accounts` doit correspondre à ce
|
||||
dernier via `ApiUsername`, avec `ApiPassword` et `Signature`.
|
||||
Conserver les identifiants dans les secrets ou l'environnement, jamais dans
|
||||
un fichier versionné. Sans configuration exploitable, la route renvoie `503`
|
||||
sans enregistrer de paiement ; une panne ou une réponse invalide PayPal
|
||||
renvoie `502`/`504`, sans modifier le solde.
|
||||
|
||||
Le client partagé `InvoicesApiClient` expose les quatre opérations.
|
||||
Le document `/openapi/invoicing.json` est réservé aux administrateurs en
|
||||
développement, comme les autres documents OpenAPI du serveur.
|
||||
Des exemples de requêtes sont fournis dans `src/Yavsc.Api/Invoicing.http` ;
|
||||
adapter l'adresse du serveur et les jetons. Aucun écran PostIt n'est ajouté
|
||||
dans ce lot.
|
||||
|
||||
### Étape 5 — finaliser l'état acquitté
|
||||
|
||||
- calculer le total payé,
|
||||
- si `totalPaye >= totalDu`, alors passer la facture en état acquittée,
|
||||
- envoyer les notifications / documents associés au cas échéant.
|
||||
|
||||
Le calcul du statut est branché aux encaissements. Les notifications finales,
|
||||
le rendu documentaire, les remboursements/annulations de reçus et les
|
||||
rapprochements historiques restent hors de ce lot, tout comme l'étape 3.
|
||||
Un remboursement ou litige ultérieur n'est pas synchronisé automatiquement :
|
||||
ne pas utiliser ce journal d'encaissements comme gestion complète des
|
||||
remboursements PayPal.
|
||||
|
||||
## Tests à couvrir
|
||||
|
||||
Les tests implémentés du backend s'exécutent avec le runner MTP du dépôt :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
|
||||
--filter-class '*InvoiceApiTests' --filter-class '*HairCutQueryApiControllerTests'
|
||||
dotnet test --project src/test/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj \
|
||||
--filter-class '*PaymentSummaryTests' --filter-class '*PayPalTransactionVerifierTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*InvoicesApiClientTests'
|
||||
```
|
||||
|
||||
Les tests HTTP utilisent la base relationnelle isolée de la fixture.
|
||||
Ils vérifient notamment qu'un ajout concurrent perdant est entièrement
|
||||
annulé (journal et total), puis rejouable. Les tests PayPal utilisent des
|
||||
réponses HTTP simulées, sans identifiants réels ni appel au prestataire.
|
||||
Ils ne remplacent pas une recette sandbox sur un compte Classic compatible.
|
||||
|
||||
Le flux doit être validé par des tests ciblés sur :
|
||||
|
||||
- devis signé par les deux parties → génération d'une facture non acquittée,
|
||||
- mode arrhes / avance → ligne de frais correctement ajoutée,
|
||||
- paiement partiel → montant enregistré sans statut payé complet,
|
||||
- paiement total → facture marquée acquittée,
|
||||
- addition / message de paiement → mention du montant réellement versé sans faux état d'acquittement complet.
|
||||
|
||||
## Critère de sortie fonctionnel
|
||||
|
||||
Le comportement attendu est considéré comme atteint si le scénario suivant est vérifié :
|
||||
|
||||
1. le devis est doublé signé,
|
||||
2. le prestataire génère une facture non acquittée,
|
||||
3. le client paye une avance ou des arrhes,
|
||||
4. la facture reste non acquittée tant qu'elle n'est pas totalement soldée,
|
||||
5. la facture devient acquittée uniquement lorsque le total est réglé.
|
||||
|
||||
## Note de contexte
|
||||
|
||||
Ce document consolide la décision métier issue du workflow “Prestation / devis” et est volontairement limité au périmètre de la facturation et du paiement après signature du devis. Il ne remplace pas les règles plus larges de gestion des litiges, workflows d'annulation ou paiements transverses déjà documentés ailleurs.
|
||||
|
|
@ -65,6 +65,20 @@ Rituels recommandes:
|
|||
- Mensuel: mise a jour du present guide et du playbook.
|
||||
- A chaque incident: ajouter un anti-pattern dans le playbook.
|
||||
|
||||
### Reseau de versionnement / git flow pour le CHANGELOG
|
||||
|
||||
Regle obligatoire:
|
||||
- La section courante du CHANGELOG représente la prochaine version a publier, pas un tag deja créé.
|
||||
- Un tag deja publie bloque définitivement la section correspondante du CHANGELOG.
|
||||
- Au commit suivant, si la modification impacte le CHANGELOG, il faut incrémenter la version exposée dans le changelog (version du prochain tag attendu), par exemple: `1.0.8-rc30 -> 1.0.8-rc31`.
|
||||
|
||||
Ordre a respecter a chaque commit qui modifie le produit:
|
||||
1. Verifier si le dernier tag du CHANGELOG existe deja; si oui, incrémenter la version de la section suivante au lieu de re-écrire la dernière section bloquée.
|
||||
2. Ouvrir une nouvelle section du CHANGELOG avec ce numero de version incrémenté.
|
||||
3. Apres seulement, alimenter cette section avec la description du changement.
|
||||
|
||||
En pratique: la version du CHANGELOG est la version "future" a tagger, et elle est incrémentée avant l'écriture de la description; le tag n'est pas créé avant la section, il est seulement établi ensuite lors de la publication effective.
|
||||
|
||||
## Check-list de validation
|
||||
|
||||
- Le changement indique son impact architecture.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,83 @@ HTTP, on stub `IAuthorizationService` directement (cf.
|
|||
`BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`)
|
||||
pour éviter de monter un host complet.
|
||||
|
||||
## Tests cibles des podcasts
|
||||
|
||||
Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
|
||||
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests'
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests'
|
||||
```
|
||||
|
||||
Les tests serveur couvrent HTTP/authentification, proprietaire, sequences,
|
||||
reprises idempotentes, limites exactes, concurrence et persistance des replays
|
||||
apres interruption/redemarrage, dans des dossiers temporaires isoles.
|
||||
|
||||
Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`.
|
||||
Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir
|
||||
cette variable sur un fichier video local d'environ huit secondes contenant
|
||||
une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la
|
||||
reprise apres acquittement perdu, le dernier segment apres arret et le
|
||||
decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni
|
||||
par l'application (le fichier d'entree peut provenir de n'importe quelle source).
|
||||
Les tests materiels camera/microphone Windows/Linux restent manuels.
|
||||
|
||||
### Rotation de la capture Android
|
||||
|
||||
Le calcul capteur/ecran/camera avant et les quatre transformations de texture
|
||||
sont couverts sans appareil :
|
||||
|
||||
```bash
|
||||
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
|
||||
--filter-class '*CameraFrameOrientationTests' --filter-class '*CameraFrameClockTests'
|
||||
```
|
||||
|
||||
Un test Android supplementaire verifie le chemin OpenGL et l'encodeur reels,
|
||||
sans ouvrir la camera, sans microphone et sans contacter l'API :
|
||||
|
||||
```bash
|
||||
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Debug \
|
||||
-p:EmbedAssembliesIntoApk=true
|
||||
adb install -r -t src/PostIt/PostIt.Android/bin/Debug/net10.0-android/fr.pschneider.postit-Signed.apk
|
||||
adb shell am start -n fr.pschneider.postit/fr.pschneider.postit.CameraFrameRotationTestActivity
|
||||
adb shell run-as fr.pschneider.postit cat files/camera-rotation-test-result.txt
|
||||
```
|
||||
|
||||
Attendre la fin du test : `RUNNING` doit devenir `PASS`. Il encode quatre
|
||||
quadrants colores en MPEG-TS pour 0, 90, 180 et 270 degres, decode une image et
|
||||
verifie les dimensions (640x480 ou 480x640) et les couleurs aux quatre positions.
|
||||
La tolerance de chaque composante RGB est de 40 pour tenir compte de H.264.
|
||||
Une erreur de rendu, d'encodage, de decodage ou de comparaison produit `FAIL`
|
||||
avec l'exception. Cette activite est exclue des builds Release.
|
||||
|
||||
Le test synthetique ne couvre pas l'horloge Camera2 ni sa synchronisation avec
|
||||
le microphone. Avec l'accord de la personne utilisant le telephone, lancer
|
||||
aussi le test local reel (camera et microphone actifs, aucun envoi a l'API) :
|
||||
|
||||
```bash
|
||||
adb shell am start -n fr.pschneider.postit/fr.pschneider.postit.PodcastCaptureTestActivity --es microphone true
|
||||
adb shell run-as fr.pschneider.postit cat files/podcast-capture-test-result.txt
|
||||
```
|
||||
|
||||
Attendre `PASS` : au moins trois segments, 8 a 16 secondes capturees,
|
||||
pistes H.264 et AAC presentes, et progression des segments en arriere-plan.
|
||||
Ce test detecte notamment les captures de zero seconde dues au decalage entre
|
||||
l'horloge Camera2 et celle de MediaRecorder. Les fichiers restent dans le
|
||||
repertoire prive indique par le resultat pour verification locale.
|
||||
Le resultat inclut les angles capteur/ecran et la premiere matrice
|
||||
SurfaceTexture recue. Les tests unitaires couvrent notamment la matrice
|
||||
reelle `[0,-1,0,0,-1,0,0,0,0,0,1,0,1,1,0,1]` : pour un capteur a 90 degres
|
||||
et un ecran a 0, elle porte deja la rotation et ne doit pas recevoir 90 degres
|
||||
supplementaires. Le test synthetique seul ne detecte pas cette double rotation.
|
||||
|
||||
Verifier aussi manuellement les cameras avant/arriere en portrait et paysage,
|
||||
le son, le direct et le replay, et la poursuite de l'emission en arriere-plan.
|
||||
L'orientation est fixee au demarrage d'une emission ; tourner le telephone
|
||||
pendant l'emission ne reconfigure pas le format encode.
|
||||
|
||||
## Quand ne PAS écrire de test
|
||||
|
||||
Un test qui ne détecte rien n'est pas un test. Si l'invariant
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
|
||||
<AvaloniaVersionBase>12.1.4</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
|
|
@ -13,18 +13,22 @@
|
|||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.4" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
|
||||
<PackageVersion Include="DirectShowLib" Version="1.0.0" />
|
||||
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.2" />
|
||||
<PackageVersion Include="LibVLCSharp" Version="3.10.2" />
|
||||
<PackageVersion Include="VideoLAN.LibVLC.Android" Version="3.7.0-beta" />
|
||||
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.2" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
|
||||
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
|
||||
<PackageVersion Include="PvRecorder" Version="1.2.13" />
|
||||
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||
|
|
@ -34,6 +38,6 @@
|
|||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Styling;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.Services;
|
||||
|
||||
[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)]
|
||||
[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)]
|
||||
|
|
@ -20,6 +21,7 @@ namespace PostIt.Android
|
|||
{
|
||||
protected Application(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
|
||||
{
|
||||
Platform.IsPodcastSupported = true;
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public class MainActivity : AvaloniaMainActivity
|
|||
/// The current MainActivity instance.
|
||||
/// </summary>
|
||||
public static MainActivity? Current { get; private set; }
|
||||
internal static bool IsForeground { get; private set; }
|
||||
|
||||
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
|
||||
{
|
||||
|
|
@ -36,8 +37,8 @@ public class MainActivity : AvaloniaMainActivity
|
|||
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
|
||||
EmojiCompat.Init(config);
|
||||
PlatformBootstrap.InitPlatform();
|
||||
base.OnCreate(savedInstanceState);
|
||||
Current = this;
|
||||
base.OnCreate(savedInstanceState);
|
||||
}
|
||||
/// <summary>
|
||||
/// Receives the deep-link Intent fired by the system browser after the
|
||||
|
|
@ -60,9 +61,34 @@ public class MainActivity : AvaloniaMainActivity
|
|||
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
|
||||
protected override void OnStop()
|
||||
{
|
||||
if (PostIt.Android.Services.AndroidCurrentLocationProvider
|
||||
PostIt.Services.Platform.SuspendPlayback();
|
||||
base.OnStop();
|
||||
}
|
||||
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
IsForeground = true;
|
||||
}
|
||||
|
||||
protected override void OnPause()
|
||||
{
|
||||
IsForeground = false;
|
||||
base.OnPause();
|
||||
}
|
||||
|
||||
protected override void OnRestart()
|
||||
{
|
||||
base.OnRestart();
|
||||
PostIt.Services.Platform.ResumePlayback();
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
|
||||
{
|
||||
if (PostIt.Android.Services.PodcastPermissions.HandleResult(requestCode, permissions, grantResults)
|
||||
|| PostIt.Android.Services.AndroidCurrentLocationProvider
|
||||
.HandlePermissionResult(requestCode, grantResults))
|
||||
{
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -21,5 +21,12 @@ internal static class PlatformBootstrap
|
|||
};
|
||||
|
||||
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
|
||||
Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
|
||||
if (System.OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;
|
||||
Platform.RequestPodcastPermissionsAsync = AndroidPodcastCapture.RequestPermissionsAsync;
|
||||
Platform.CreatePodcastCaptureAsync = AndroidPodcastCapture.StartAsync;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Icon.png">
|
||||
|
|
@ -21,20 +21,25 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" />
|
||||
<PackageReference Include="LibVLCSharp" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Android" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Browser" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
|
||||
<Compile Include="..\PostIt.Tests\AndroidCameraFrameRotationTestActivity.cs" Link="Tests\AndroidCameraFrameRotationTestActivity.cs" />
|
||||
</ItemGroup>
|
||||
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
|
||||
extension's injected HotReload agent (references Microsoft.Maui.*).
|
||||
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
||||
this file's item groups, so the removal must run in a target. -->
|
||||
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
|
||||
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
|
||||
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile" Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
|
||||
<ItemGroup>
|
||||
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
<?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" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<application
|
||||
android:label="PostIt"
|
||||
android:icon="@drawable/Icon"
|
||||
|
|
|
|||
308
src/PostIt/PostIt.Android/Services/AndroidCameraFrameRotator.cs
Normal file
308
src/PostIt/PostIt.Android/Services/AndroidCameraFrameRotator.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Android.Graphics;
|
||||
using Android.Opengl;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Java.Nio;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal sealed class AndroidCameraFrameRotator : IDisposable
|
||||
{
|
||||
private const int ExternalTexture = 0x8D65;
|
||||
private const int RecordableAndroid = 0x3142;
|
||||
private const string VertexShader = """
|
||||
attribute vec2 position;
|
||||
attribute vec2 textureCoordinate;
|
||||
uniform mat4 textureMatrix;
|
||||
varying vec2 uv;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
uv = (textureMatrix * vec4(textureCoordinate, 0.0, 1.0)).xy;
|
||||
}
|
||||
""";
|
||||
private const string FragmentShader = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
uniform samplerExternalOES cameraTexture;
|
||||
varying vec2 uv;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(cameraTexture, uv);
|
||||
}
|
||||
""";
|
||||
|
||||
private readonly HandlerThread _thread = new("PostIt.CameraRotation");
|
||||
private readonly Handler _handler;
|
||||
private readonly Action<Exception> _failed;
|
||||
private readonly float[] _textureMatrix = new float[16];
|
||||
private readonly CameraFrameClock _clock = new();
|
||||
private EGLDisplay? _display;
|
||||
private EGLContext? _context;
|
||||
private EGLSurface? _window;
|
||||
private SurfaceTexture? _texture;
|
||||
private FrameListener? _listener;
|
||||
private Surface? _cameraSurface;
|
||||
private FloatBuffer? _vertices;
|
||||
private FloatBuffer? _coordinates;
|
||||
private int _program;
|
||||
private int _textureId;
|
||||
private int _matrixLocation;
|
||||
private bool _rendering;
|
||||
private bool _disposed;
|
||||
private int _rotation;
|
||||
private int? _remainingRotation;
|
||||
internal string? FirstTextureTransform { get; private set; }
|
||||
|
||||
internal Surface CameraSurface => _cameraSurface
|
||||
?? throw new InvalidOperationException("Surface de rotation camera indisponible.");
|
||||
|
||||
private AndroidCameraFrameRotator(Action<Exception> failed)
|
||||
{
|
||||
_failed = failed;
|
||||
_thread.Start();
|
||||
_handler = new Handler(_thread.Looper!);
|
||||
}
|
||||
|
||||
internal static async Task<AndroidCameraFrameRotator> CreateAsync(
|
||||
Surface encoderSurface, int width, int height, int rotation, Action<Exception> failed)
|
||||
{
|
||||
var rotator = new AndroidCameraFrameRotator(failed);
|
||||
try
|
||||
{
|
||||
await rotator.InvokeAsync(() => rotator.Initialize(encoderSurface, width, height, rotation));
|
||||
return rotator;
|
||||
}
|
||||
catch
|
||||
{
|
||||
rotator.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void Initialize(Surface encoderSurface, int width, int height, int rotation)
|
||||
{
|
||||
_rotation = rotation;
|
||||
_display = EGL14.EglGetDisplay(EGL14.EglDefaultDisplay);
|
||||
if (_display is null || _display.Equals(EGL14.EglNoDisplay))
|
||||
throw EglError("Obtention de l'affichage EGL");
|
||||
if (!EGL14.EglInitialize(_display, new int[2], 0, new int[2], 0))
|
||||
throw EglError("Initialisation EGL");
|
||||
int[] attributes =
|
||||
[
|
||||
EGL14.EglRedSize, 8, EGL14.EglGreenSize, 8, EGL14.EglBlueSize, 8,
|
||||
EGL14.EglRenderableType, EGL14.EglOpenglEs2Bit,
|
||||
EGL14.EglSurfaceType, EGL14.EglWindowBit, RecordableAndroid, 1, EGL14.EglNone
|
||||
];
|
||||
var configs = new EGLConfig[1];
|
||||
var count = new int[1];
|
||||
if (!EGL14.EglChooseConfig(_display, attributes, 0, configs, 0, 1, count, 0) || count[0] == 0)
|
||||
throw EglError("Configuration EGL pour l'encodeur");
|
||||
_context = EGL14.EglCreateContext(_display, configs[0], EGL14.EglNoContext,
|
||||
[EGL14.EglContextClientVersion, 2, EGL14.EglNone], 0);
|
||||
if (_context is null || _context.Equals(EGL14.EglNoContext))
|
||||
throw EglError("Creation du contexte EGL");
|
||||
_window = EGL14.EglCreateWindowSurface(_display, configs[0], encoderSurface, [EGL14.EglNone], 0);
|
||||
if (_window is null || _window.Equals(EGL14.EglNoSurface))
|
||||
throw EglError("Creation de la surface EGL");
|
||||
if (!EGL14.EglMakeCurrent(_display, _window, _window, _context))
|
||||
throw EglError("Activation du contexte EGL");
|
||||
|
||||
_program = CreateProgram();
|
||||
var ids = new int[1];
|
||||
GLES20.GlGenTextures(1, ids, 0);
|
||||
_textureId = ids[0];
|
||||
GLES20.GlBindTexture(ExternalTexture, _textureId);
|
||||
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureMinFilter, GLES20.GlLinear);
|
||||
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureMagFilter, GLES20.GlLinear);
|
||||
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureWrapS, GLES20.GlClampToEdge);
|
||||
GLES20.GlTexParameteri(ExternalTexture, GLES20.GlTextureWrapT, GLES20.GlClampToEdge);
|
||||
_vertices = CreateBuffer([-1, -1, 1, -1, -1, 1, 1, 1]);
|
||||
_coordinates = CreateBuffer(CameraFrameOrientation.GetTextureCoordinates(0));
|
||||
GLES20.GlUseProgram(_program);
|
||||
BindAttribute("position", _vertices);
|
||||
BindAttribute("textureCoordinate", _coordinates);
|
||||
_matrixLocation = GLES20.GlGetUniformLocation(_program, "textureMatrix");
|
||||
GLES20.GlUniform1i(GLES20.GlGetUniformLocation(_program, "cameraTexture"), 0);
|
||||
GLES20.GlViewport(0, 0, rotation is 90 or 270 ? height : width, rotation is 90 or 270 ? width : height);
|
||||
CheckGl();
|
||||
|
||||
_texture = new SurfaceTexture(_textureId);
|
||||
_texture.SetDefaultBufferSize(width, height);
|
||||
_listener = new FrameListener(this);
|
||||
_texture.SetOnFrameAvailableListener(_listener, _handler);
|
||||
_cameraSurface = new Surface(_texture);
|
||||
}
|
||||
|
||||
internal Task StartAsync() => InvokeAsync(() => _rendering = true);
|
||||
|
||||
private void DrawFrame(SurfaceTexture texture)
|
||||
{
|
||||
if (!_rendering) return;
|
||||
try
|
||||
{
|
||||
texture.UpdateTexImage();
|
||||
var presentationTime = _clock.GetPresentationTime(texture.Timestamp, Java.Lang.JavaSystem.NanoTime());
|
||||
if (presentationTime is null) return;
|
||||
texture.GetTransformMatrix(_textureMatrix);
|
||||
var remainingRotation = CameraFrameOrientation.GetRemainingRotation(_rotation, _textureMatrix);
|
||||
if (_remainingRotation != remainingRotation)
|
||||
{
|
||||
_remainingRotation = remainingRotation;
|
||||
FirstTextureTransform = string.Join(", ", _textureMatrix);
|
||||
_coordinates!.Position(0);
|
||||
_coordinates.Put(CameraFrameOrientation.GetTextureCoordinates(remainingRotation));
|
||||
_coordinates.Position(0);
|
||||
global::Android.Util.Log.Info("PostIt.Podcast",
|
||||
$"Camera texture transform: [{FirstTextureTransform}], remaining rotation={remainingRotation}");
|
||||
}
|
||||
GLES20.GlUniformMatrix4fv(_matrixLocation, 1, false, _textureMatrix, 0);
|
||||
GLES20.GlDrawArrays(GLES20.GlTriangleStrip, 0, 4);
|
||||
CheckGl();
|
||||
if (!EGLExt.EglPresentationTimeANDROID(_display, _window, presentationTime.Value))
|
||||
throw EglError("Horodatage de l'image camera");
|
||||
if (!EGL14.EglSwapBuffers(_display, _window))
|
||||
throw EglError("Envoi de l'image a l'encodeur");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_rendering = false;
|
||||
_failed(new InvalidOperationException("Rotation de la capture Android impossible.", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private Task InvokeAsync(Action action)
|
||||
{
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (!_handler.Post(() =>
|
||||
{
|
||||
try { action(); completion.SetResult(); }
|
||||
catch (Exception ex) { completion.SetException(ex); }
|
||||
}))
|
||||
completion.SetException(new InvalidOperationException("Le thread de rotation camera est arrete."));
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
private static FloatBuffer CreateBuffer(float[] values)
|
||||
{
|
||||
using var bytes = ByteBuffer.AllocateDirect(values.Length * sizeof(float))
|
||||
?? throw new InvalidOperationException("Allocation du tampon OpenGL impossible.");
|
||||
bytes.Order(ByteOrder.NativeOrder()!);
|
||||
var buffer = bytes.AsFloatBuffer()
|
||||
?? throw new InvalidOperationException("Creation du tampon OpenGL impossible.");
|
||||
buffer.Put(values);
|
||||
buffer.Position(0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void BindAttribute(string name, FloatBuffer buffer)
|
||||
{
|
||||
var location = GLES20.GlGetAttribLocation(_program, name);
|
||||
GLES20.GlEnableVertexAttribArray(location);
|
||||
GLES20.GlVertexAttribPointer(location, 2, GLES20.GlFloat, false, 0, buffer);
|
||||
}
|
||||
|
||||
private static int CreateShader(int type, string source)
|
||||
{
|
||||
var shader = GLES20.GlCreateShader(type);
|
||||
GLES20.GlShaderSource(shader, source);
|
||||
GLES20.GlCompileShader(shader);
|
||||
var status = new int[1];
|
||||
GLES20.GlGetShaderiv(shader, GLES20.GlCompileStatus, status, 0);
|
||||
if (status[0] != 0) return shader;
|
||||
var error = GLES20.GlGetShaderInfoLog(shader);
|
||||
GLES20.GlDeleteShader(shader);
|
||||
throw new InvalidOperationException($"Compilation du shader camera impossible : {error}");
|
||||
}
|
||||
|
||||
private static int CreateProgram()
|
||||
{
|
||||
var vertex = CreateShader(GLES20.GlVertexShader, VertexShader);
|
||||
var fragment = 0;
|
||||
var program = 0;
|
||||
try
|
||||
{
|
||||
fragment = CreateShader(GLES20.GlFragmentShader, FragmentShader);
|
||||
program = GLES20.GlCreateProgram();
|
||||
GLES20.GlAttachShader(program, vertex);
|
||||
GLES20.GlAttachShader(program, fragment);
|
||||
GLES20.GlLinkProgram(program);
|
||||
var status = new int[1];
|
||||
GLES20.GlGetProgramiv(program, GLES20.GlLinkStatus, status, 0);
|
||||
if (status[0] == 0)
|
||||
throw new InvalidOperationException($"Edition du programme camera impossible : {GLES20.GlGetProgramInfoLog(program)}");
|
||||
return program;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (program != 0) GLES20.GlDeleteProgram(program);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
GLES20.GlDeleteShader(vertex);
|
||||
if (fragment != 0) GLES20.GlDeleteShader(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckGl()
|
||||
{
|
||||
var error = GLES20.GlGetError();
|
||||
if (error != GLES20.GlNoError)
|
||||
throw new InvalidOperationException($"Erreur OpenGL de capture : 0x{error:X}.");
|
||||
}
|
||||
|
||||
private static InvalidOperationException EglError(string operation)
|
||||
=> new($"{operation} impossible : EGL 0x{EGL14.EglGetError():X}.");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { InvokeAsync(Release).GetAwaiter().GetResult(); }
|
||||
finally
|
||||
{
|
||||
_thread.QuitSafely();
|
||||
_thread.Join();
|
||||
_handler.Dispose();
|
||||
_thread.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void Release()
|
||||
{
|
||||
_rendering = false;
|
||||
_texture?.SetOnFrameAvailableListener(null);
|
||||
_cameraSurface?.Release();
|
||||
_cameraSurface?.Dispose();
|
||||
_texture?.Release();
|
||||
_texture?.Dispose();
|
||||
_listener?.Dispose();
|
||||
if (_program != 0) GLES20.GlDeleteProgram(_program);
|
||||
if (_textureId != 0) GLES20.GlDeleteTextures(1, [_textureId], 0);
|
||||
_vertices?.Dispose();
|
||||
_coordinates?.Dispose();
|
||||
if (_display is not null && !_display.Equals(EGL14.EglNoDisplay))
|
||||
{
|
||||
EGL14.EglMakeCurrent(_display, EGL14.EglNoSurface, EGL14.EglNoSurface, EGL14.EglNoContext);
|
||||
if (_window is not null && !_window.Equals(EGL14.EglNoSurface))
|
||||
EGL14.EglDestroySurface(_display, _window);
|
||||
if (_context is not null && !_context.Equals(EGL14.EglNoContext))
|
||||
EGL14.EglDestroyContext(_display, _context);
|
||||
EGL14.EglReleaseThread();
|
||||
EGL14.EglTerminate(_display);
|
||||
}
|
||||
_window?.Dispose();
|
||||
_context?.Dispose();
|
||||
_display?.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FrameListener(AndroidCameraFrameRotator owner)
|
||||
: Java.Lang.Object, SurfaceTexture.IOnFrameAvailableListener
|
||||
{
|
||||
public void OnFrameAvailable(SurfaceTexture? texture)
|
||||
{
|
||||
if (texture is not null) owner.DrawFrame(texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
348
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal file
348
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.Content;
|
||||
using Android.Hardware.Camera2;
|
||||
using Android.Hardware.Camera2.Params;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
internal sealed class AndroidPodcastCapture : IPodcastCapture
|
||||
{
|
||||
private readonly PodcastCaptureService _service;
|
||||
private readonly SemaphoreSlim _stopGate = new(1);
|
||||
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly Handler _handler = new(Looper.MainLooper!);
|
||||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private MediaRecorder? _recorder;
|
||||
private AndroidCameraFrameRotator? _rotator;
|
||||
private Surface? _surface;
|
||||
private ParcelFileDescriptor? _readPipe;
|
||||
private ParcelFileDescriptor? _writePipe;
|
||||
private Task _pump = Task.CompletedTask;
|
||||
private Exception? _failure;
|
||||
private bool _stopped;
|
||||
private bool _stopRequested;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
public Task Completion => _completion.Task;
|
||||
internal string OrientationDiagnostics { get; private set; } = "";
|
||||
internal string? TextureTransform => _rotator?.FirstTextureTransform;
|
||||
|
||||
private AndroidPodcastCapture(PodcastCaptureService service) => _service = service;
|
||||
|
||||
private static CameraManager GetManager(Context context)
|
||||
=> context.GetSystemService(Context.CameraService) as CameraManager
|
||||
?? throw new InvalidOperationException("Service camera Android indisponible.");
|
||||
|
||||
public static PodcastCaptureDevices GetDevices()
|
||||
{
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
var manager = GetManager(activity);
|
||||
var cameras = new List<CaptureDevice>();
|
||||
foreach (var id in manager.GetCameraIdList())
|
||||
{
|
||||
using var characteristics = manager.GetCameraCharacteristics(id);
|
||||
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer;
|
||||
cameras.Add(new(facing?.IntValue() == (int)LensFacing.Front ? "Camera avant" : "Camera arriere", id));
|
||||
}
|
||||
return new(cameras, [new("Sans microphone", ""), new("Microphone Android", "microphone")]);
|
||||
}
|
||||
|
||||
public static async Task RequestPermissionsAsync(string? microphone, CancellationToken ct)
|
||||
{
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
await PodcastPermissions.EnsureAsync(activity, microphone is not null, ct);
|
||||
if (!MainActivity.IsForeground)
|
||||
throw new InvalidOperationException("Revenez dans PostIt pour demarrer la camera.");
|
||||
}
|
||||
|
||||
public static async Task<IPodcastCapture> StartAsync(string camera, string? microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
await RequestPermissionsAsync(microphone, ct);
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct);
|
||||
var capture = new AndroidPodcastCapture(service);
|
||||
service.Capture = capture;
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await capture.StartNativeAsync(camera, microphone is not null, directory, ct);
|
||||
return capture;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await capture.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
await _stopGate.WaitAsync(ct);
|
||||
try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); }
|
||||
finally { _stopGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
var manager = GetManager(_service);
|
||||
using var characteristics = manager.GetCameraCharacteristics(cameraId);
|
||||
using var formats = characteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap) as StreamConfigurationMap
|
||||
?? throw new InvalidOperationException("Formats camera indisponibles.");
|
||||
var sizes = formats.GetOutputSizes(Java.Lang.Class.FromType(typeof(MediaRecorder)))
|
||||
?? throw new InvalidOperationException("Aucun format video disponible.");
|
||||
var size = sizes.Where(s => s.Width <= 640 && s.Height <= 480)
|
||||
.OrderByDescending(s => s.Width * s.Height).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("La camera ne propose aucun format jusqu'a 480p.");
|
||||
using var sensor = characteristics.Get(CameraCharacteristics.SensorOrientation) as Java.Lang.Integer
|
||||
?? throw new InvalidOperationException("Orientation du capteur camera indisponible.");
|
||||
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer
|
||||
?? throw new InvalidOperationException("Position du capteur camera indisponible.");
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
#pragma warning disable CS0618, CA1422
|
||||
var display = OperatingSystem.IsAndroidVersionAtLeast(30)
|
||||
? activity.Display : activity.WindowManager?.DefaultDisplay;
|
||||
#pragma warning restore CS0618, CA1422
|
||||
var displayRotation = display?.Rotation
|
||||
?? throw new InvalidOperationException("Orientation de l'ecran Android indisponible.");
|
||||
var displayDegrees = displayRotation switch
|
||||
{
|
||||
SurfaceOrientation.Rotation0 => 0,
|
||||
SurfaceOrientation.Rotation90 => 90,
|
||||
SurfaceOrientation.Rotation180 => 180,
|
||||
SurfaceOrientation.Rotation270 => 270,
|
||||
_ => throw new InvalidOperationException($"Rotation d'ecran Android inconnue : {displayRotation}.")
|
||||
};
|
||||
var rotation = CameraFrameOrientation.GetRotation(sensor.IntValue(), displayDegrees,
|
||||
facing.IntValue() == (int)LensFacing.Front);
|
||||
global::Android.Util.Log.Info("PostIt.Podcast",
|
||||
$"Camera orientation: sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}");
|
||||
OrientationDiagnostics = $"sensor={sensor.IntValue()}, display={displayDegrees}, facing={facing.IntValue()}, rotation={rotation}";
|
||||
#pragma warning disable CS0618
|
||||
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
|
||||
? new MediaRecorder(_service) : new MediaRecorder();
|
||||
#pragma warning restore CS0618
|
||||
_recorder.Error += OnRecorderError;
|
||||
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
|
||||
_recorder.SetVideoSource(VideoSource.Surface);
|
||||
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
|
||||
_recorder.SetVideoSize(rotation is 90 or 270 ? size.Height : size.Width,
|
||||
rotation is 90 or 270 ? size.Width : size.Height);
|
||||
_recorder.SetVideoFrameRate(30);
|
||||
_recorder.SetVideoEncodingBitRate(1200000);
|
||||
_recorder.SetVideoEncoder(VideoEncoder.H264);
|
||||
if (microphone)
|
||||
{
|
||||
_recorder.SetAudioSamplingRate(48000);
|
||||
_recorder.SetAudioChannels(1);
|
||||
_recorder.SetAudioEncodingBitRate(96000);
|
||||
_recorder.SetAudioEncoder(AudioEncoder.Aac);
|
||||
}
|
||||
var pipe = ParcelFileDescriptor.CreatePipe()
|
||||
?? throw new IOException("Creation du canal MPEG-TS impossible.");
|
||||
_readPipe = pipe[0];
|
||||
_writePipe = pipe[1];
|
||||
_recorder.SetOutputFile(_writePipe.FileDescriptor);
|
||||
await Task.Run(_recorder.Prepare, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
|
||||
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
|
||||
_rotator = await AndroidCameraFrameRotator.CreateAsync(_surface, size.Width, size.Height, rotation, Fail);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
|
||||
var cameraSurface = _rotator.CameraSurface;
|
||||
var opened = new TaskCompletionSource<CameraDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
manager.OpenCamera(cameraId, new DeviceCallback(this, opened), _handler);
|
||||
_camera = await opened.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
var configured = new TaskCompletionSource<CameraCaptureSession>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
#pragma warning disable CS0618, CA1422
|
||||
_camera.CreateCaptureSession(new List<Surface> { cameraSurface }, new SessionCallback(this, configured), _handler);
|
||||
#pragma warning restore CS0618, CA1422
|
||||
_session = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
|
||||
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
|
||||
request.AddTarget(cameraSurface);
|
||||
using var built = request.Build();
|
||||
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
|
||||
var stream = new FileStream(handle, FileAccess.Read);
|
||||
_pump = Task.Run(async () =>
|
||||
{
|
||||
using (handle)
|
||||
using (stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
await new TransportStreamCapture(directory).RunAsync(stream, CancellationToken.None);
|
||||
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
|
||||
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
|
||||
_completion.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_completion.TrySetException(ex);
|
||||
// Drain until MediaRecorder stops, so its writer cannot block Stop().
|
||||
await stream.CopyToAsync(System.IO.Stream.Null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Task.Run(_recorder.Start, ct);
|
||||
_started = true;
|
||||
await _rotator.StartAsync();
|
||||
_session.SetRepeatingRequest(built, null, _handler);
|
||||
_writePipe.Close();
|
||||
_writePipe.Dispose();
|
||||
_writePipe = null;
|
||||
}
|
||||
|
||||
private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraDevice> opened)
|
||||
: CameraDevice.StateCallback
|
||||
{
|
||||
public override void OnOpened(CameraDevice camera)
|
||||
{
|
||||
if (capture._stopRequested || opened.Task.IsCompleted)
|
||||
{
|
||||
camera.Close();
|
||||
opened.TrySetException(new IOException("Ouverture camera annulee."));
|
||||
}
|
||||
else
|
||||
{
|
||||
capture._camera = camera;
|
||||
opened.TrySetResult(camera);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnDisconnected(CameraDevice camera) => Fail(camera, "Camera Android deconnectee.");
|
||||
public override void OnError(CameraDevice camera, CameraError error) => Fail(camera, $"Erreur camera Android : {error}.");
|
||||
private void Fail(CameraDevice camera, string message)
|
||||
{
|
||||
camera.Close();
|
||||
var error = new IOException(message);
|
||||
opened.TrySetException(error);
|
||||
capture.Fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraCaptureSession> configured)
|
||||
: CameraCaptureSession.StateCallback
|
||||
{
|
||||
public override void OnConfigured(CameraCaptureSession session)
|
||||
{
|
||||
if (capture._stopRequested || configured.Task.IsCompleted)
|
||||
{
|
||||
session.Close();
|
||||
configured.TrySetException(new IOException("Configuration camera annulee."));
|
||||
}
|
||||
else
|
||||
{
|
||||
capture._session = session;
|
||||
configured.TrySetResult(session);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnConfigureFailed(CameraCaptureSession session)
|
||||
{
|
||||
session.Close();
|
||||
var error = new IOException("Configuration de la capture Android impossible.");
|
||||
configured.TrySetException(error);
|
||||
capture.Fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecorderError(object? sender, MediaRecorder.ErrorEventArgs e)
|
||||
=> Fail(new IOException($"Erreur encodeur Android : {e.What}/{e.Extra}."));
|
||||
|
||||
private void Fail(Exception error)
|
||||
{
|
||||
if (_stopped) return;
|
||||
_failure = error;
|
||||
_completion.TrySetException(error);
|
||||
RequestStop();
|
||||
}
|
||||
|
||||
internal async void RequestStop()
|
||||
{
|
||||
try { await StopAsync(); }
|
||||
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Podcast", ex.ToString()); }
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_stopRequested = true;
|
||||
await _stopGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_stopped) return;
|
||||
_stopped = true;
|
||||
await Task.Run(StopNative);
|
||||
await _pump;
|
||||
}
|
||||
finally { _stopGate.Release(); }
|
||||
}
|
||||
|
||||
private void StopNative()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Keep frames flowing until MediaRecorder has drained its encoder.
|
||||
if (_started) _recorder?.Stop();
|
||||
}
|
||||
catch (Java.Lang.RuntimeException ex)
|
||||
{
|
||||
_failure = ex;
|
||||
_completion.TrySetException(new IOException("L'encodeur Android n'a pas pu finaliser la capture.", ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_started = false;
|
||||
_session?.Close();
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_camera?.Close();
|
||||
_camera?.Dispose();
|
||||
_camera = null;
|
||||
_rotator?.Dispose();
|
||||
_rotator = null;
|
||||
if (_recorder is not null)
|
||||
{
|
||||
_recorder.Error -= OnRecorderError;
|
||||
_recorder.Release();
|
||||
_recorder.Dispose();
|
||||
_recorder = null;
|
||||
}
|
||||
_surface?.Dispose();
|
||||
_surface = null;
|
||||
_writePipe?.Close();
|
||||
_writePipe?.Dispose();
|
||||
_writePipe = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { await StopAsync(); }
|
||||
finally
|
||||
{
|
||||
_readPipe?.Dispose();
|
||||
_readPipe = null;
|
||||
_service.Capture = null;
|
||||
_service.StopSelf();
|
||||
_handler.Dispose();
|
||||
_stopGate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal file
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using Avalonia.Android;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using LibVLCSharp.Shared;
|
||||
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
|
||||
{
|
||||
private VideoView? _video;
|
||||
private bool _disposed;
|
||||
|
||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
|
||||
{
|
||||
var activity = MainActivity.Current
|
||||
?? throw new InvalidOperationException("L'activite Android est indisponible.");
|
||||
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
|
||||
_video = video;
|
||||
return new AndroidViewControlHandle(video);
|
||||
}
|
||||
|
||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
if (control is AndroidViewControlHandle { View: VideoView video })
|
||||
video.MediaPlayer = null;
|
||||
_video = null;
|
||||
base.DestroyNativeControlCore(control);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
if (_video is not null) _video.MediaPlayer = null;
|
||||
}
|
||||
}
|
||||
24
src/PostIt/PostIt.Android/Services/CameraFrameClock.cs
Normal file
24
src/PostIt/PostIt.Android/Services/CameraFrameClock.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal sealed class CameraFrameClock
|
||||
{
|
||||
private long? _offset;
|
||||
private long? _previousTimestamp;
|
||||
|
||||
internal long? GetPresentationTime(long cameraTimestamp, long monotonicNow)
|
||||
{
|
||||
if (_previousTimestamp is { } previous)
|
||||
{
|
||||
if (cameraTimestamp == previous) return null;
|
||||
if (cameraTimestamp < previous)
|
||||
throw new InvalidOperationException("Les horodatages de la camera Android ont recule.");
|
||||
}
|
||||
// Camera2 may use elapsed realtime (including suspend), whereas MediaRecorder
|
||||
// and its microphone use monotonic time. Preserve frame intervals, not that epoch.
|
||||
_offset ??= monotonicNow - cameraTimestamp;
|
||||
_previousTimestamp = cameraTimestamp;
|
||||
return checked(cameraTimestamp + _offset.Value);
|
||||
}
|
||||
}
|
||||
55
src/PostIt/PostIt.Android/Services/CameraFrameOrientation.cs
Normal file
55
src/PostIt/PostIt.Android/Services/CameraFrameOrientation.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class CameraFrameOrientation
|
||||
{
|
||||
internal static int GetRotation(int sensorDegrees, int displayDegrees, bool frontFacing)
|
||||
{
|
||||
Validate(sensorDegrees);
|
||||
Validate(displayDegrees);
|
||||
return (sensorDegrees + (frontFacing ? displayDegrees : -displayDegrees) + 360) % 360;
|
||||
}
|
||||
|
||||
internal static float[] GetTextureCoordinates(int clockwiseDegrees)
|
||||
{
|
||||
Validate(clockwiseDegrees);
|
||||
float[] corners = [0, 0, 1, 0, 0, 1, 1, 1];
|
||||
for (var i = 0; i < corners.Length; i += 2)
|
||||
{
|
||||
var x = corners[i];
|
||||
var y = corners[i + 1];
|
||||
(corners[i], corners[i + 1]) = clockwiseDegrees switch
|
||||
{
|
||||
90 => (1 - y, x),
|
||||
180 => (1 - x, 1 - y),
|
||||
270 => (y, 1 - x),
|
||||
_ => (x, y)
|
||||
};
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
|
||||
internal static int GetRemainingRotation(int clockwiseDegrees, float[] textureMatrix)
|
||||
{
|
||||
Validate(clockwiseDegrees);
|
||||
if (textureMatrix.Length != 16)
|
||||
throw new ArgumentException("A SurfaceTexture transform must contain 16 elements.", nameof(textureMatrix));
|
||||
// SurfaceTexture's usual Y flip changes the sign of the first column's Y.
|
||||
// Camera2 can also put the sensor rotation in this matrix; don't apply it twice.
|
||||
var angle = Math.Atan2(-textureMatrix[1], textureMatrix[0]) * 180 / Math.PI;
|
||||
if (!double.IsFinite(angle) || (textureMatrix[0] == 0 && textureMatrix[1] == 0))
|
||||
throw new ArgumentException("Invalid SurfaceTexture orientation.", nameof(textureMatrix));
|
||||
var quarterTurns = (int)Math.Round(angle / 90);
|
||||
if (Math.Abs(angle - quarterTurns * 90) > 0.01)
|
||||
throw new ArgumentException("SurfaceTexture orientation is not a quarter turn.", nameof(textureMatrix));
|
||||
var producerRotation = (quarterTurns * 90 + 360) % 360;
|
||||
return (clockwiseDegrees - producerRotation + 360) % 360;
|
||||
}
|
||||
|
||||
private static void Validate(int degrees)
|
||||
{
|
||||
if (degrees is not (0 or 90 or 180 or 270))
|
||||
throw new ArgumentOutOfRangeException(nameof(degrees), degrees, "Rotation must be 0, 90, 180 or 270 degrees.");
|
||||
}
|
||||
}
|
||||
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal file
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using AndroidX.Core.App;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[Service(Exported = false, ForegroundServiceType = ForegroundService.TypeCamera | ForegroundService.TypeMicrophone)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class PodcastCaptureService : Service
|
||||
{
|
||||
private const string Channel = "podcast-broadcast";
|
||||
internal const string StopAction = "fr.pschneider.postit.STOP_PODCAST";
|
||||
private const int NotificationId = 4044;
|
||||
private static TaskCompletionSource<PodcastCaptureService>? _pending;
|
||||
private PowerManager.WakeLock? _wakeLock;
|
||||
internal AndroidPodcastCapture? Capture { get; set; }
|
||||
|
||||
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone, CancellationToken ct)
|
||||
{
|
||||
if (_pending is not null) throw new InvalidOperationException("Un direct Android est deja en cours.");
|
||||
var pending = new TaskCompletionSource<PodcastCaptureService>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = pending;
|
||||
try
|
||||
{
|
||||
var intent = new Intent(activity, typeof(PodcastCaptureService)).PutExtra("microphone", microphone);
|
||||
activity.StartForegroundService(intent);
|
||||
return await pending.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
activity.StopService(new Intent(activity, typeof(PodcastCaptureService)));
|
||||
_pending = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||
{
|
||||
if (intent?.Action == StopAction)
|
||||
{
|
||||
if (Capture is { } capture) capture.RequestStop();
|
||||
else
|
||||
{
|
||||
_pending?.TrySetException(new System.OperationCanceledException("Demarrage du direct annule depuis la notification."));
|
||||
StopSelf();
|
||||
}
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
try
|
||||
{
|
||||
var manager = GetSystemService(NotificationService) as NotificationManager
|
||||
?? throw new InvalidOperationException("Notifications Android indisponibles.");
|
||||
manager.CreateNotificationChannel(new NotificationChannel(Channel, "Diffusion podcast", NotificationImportance.Low));
|
||||
var launch = PackageManager?.GetLaunchIntentForPackage(PackageName!)
|
||||
?? throw new InvalidOperationException("Application Android indisponible.");
|
||||
var open = PendingIntent.GetActivity(this, 0, launch, PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
var stop = PendingIntent.GetService(this, 1,
|
||||
new Intent(this, typeof(PodcastCaptureService)).SetAction(StopAction),
|
||||
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
var builder = new NotificationCompat.Builder(this, Channel);
|
||||
builder.SetSmallIcon(global::Android.Resource.Drawable.IcMenuCamera);
|
||||
builder.SetContentTitle("Podcast en direct");
|
||||
builder.SetContentText("Camera active. Toucher pour revenir au direct.");
|
||||
builder.SetContentIntent(open);
|
||||
builder.SetOngoing(true);
|
||||
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, "Arreter et publier", stop);
|
||||
var notification = builder.Build();
|
||||
var type = (ForegroundService)0;
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(30))
|
||||
{
|
||||
type = ForegroundService.TypeCamera;
|
||||
if (intent?.GetBooleanExtra("microphone", false) == true) type |= ForegroundService.TypeMicrophone;
|
||||
}
|
||||
ServiceCompat.StartForeground(this, NotificationId, notification, (int)type);
|
||||
var power = GetSystemService(PowerService) as PowerManager
|
||||
?? throw new InvalidOperationException("Gestion d'alimentation Android indisponible.");
|
||||
_wakeLock = power.NewWakeLock(WakeLockFlags.Partial, "PostIt:Podcast")
|
||||
?? throw new InvalidOperationException("Le verrou d'alimentation Android est indisponible.");
|
||||
_wakeLock.Acquire();
|
||||
_pending?.TrySetResult(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pending?.TrySetException(ex);
|
||||
StopSelf();
|
||||
}
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
|
||||
public override IBinder? OnBind(Intent? intent) => null;
|
||||
|
||||
public override void OnDestroy()
|
||||
{
|
||||
_pending?.TrySetException(new InvalidOperationException("Le service de diffusion Android a ete arrete."));
|
||||
Capture?.RequestStop();
|
||||
Capture = null;
|
||||
if (_wakeLock?.IsHeld == true) _wakeLock.Release();
|
||||
_wakeLock?.Dispose();
|
||||
_wakeLock = null;
|
||||
_pending = null;
|
||||
base.OnDestroy();
|
||||
}
|
||||
}
|
||||
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal file
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android;
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using AndroidX.Core.App;
|
||||
using AndroidX.Core.Content;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class PodcastPermissions
|
||||
{
|
||||
private const int RequestCode = 4043;
|
||||
private static TaskCompletionSource<bool>? _pending;
|
||||
private static string[] _requested = [];
|
||||
|
||||
public static async Task EnsureAsync(Activity activity, bool microphone, CancellationToken ct)
|
||||
{
|
||||
var requested = new List<string> { Manifest.Permission.Camera };
|
||||
if (microphone) requested.Add(Manifest.Permission.RecordAudio);
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(33)) requested.Add(Manifest.Permission.PostNotifications);
|
||||
var permissions = requested.ToArray();
|
||||
if (permissions.All(p => ContextCompat.CheckSelfPermission(activity, p) == Permission.Granted)) return;
|
||||
if (_pending is not null) throw new InvalidOperationException("Une demande de permissions est deja en cours.");
|
||||
_requested = permissions;
|
||||
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = pending;
|
||||
ActivityCompat.RequestPermissions(activity, permissions, RequestCode);
|
||||
if (!await pending.Task.WaitAsync(ct))
|
||||
throw new UnauthorizedAccessException("Autorisez la camera, les notifications et, si selectionne, le microphone pour diffuser.");
|
||||
}
|
||||
|
||||
public static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)
|
||||
{
|
||||
if (requestCode != RequestCode) return false;
|
||||
var granted = permissions is not null && results is not null && permissions.Length == results.Length
|
||||
&& _requested.All(p => Array.IndexOf(permissions, p) is var index && index >= 0
|
||||
&& results[index] == Permission.Granted);
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.TrySetResult(granted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
src/PostIt/PostIt.Android/proguard-rules.pro
vendored
Normal file
9
src/PostIt/PostIt.Android/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Keep the LibVLC Android classes and JNI/native bindings intact in Release builds.
|
||||
# R8/ProGuard otherwise can strip or rename the Java-side entry points that load
|
||||
# the native libvlc and libc++_shared libraries bundled by VideoLAN.LibVLC.Android.
|
||||
-keep class org.videolan.libvlc.** { *; }
|
||||
-keep class org.videolan.libvlc.** { native *; }
|
||||
-keep class org.videolan.medialibrary.** { *; }
|
||||
-keepattributes *Annotation*
|
||||
-dontwarn org.videolan.libvlc.**
|
||||
-dontwarn org.videolan.medialibrary.**
|
||||
|
|
@ -4,10 +4,10 @@
|
|||
<OutputType>Exe</OutputType>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Browser" />
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Browser;
|
||||
using PostIt;
|
||||
using PostIt.Services;
|
||||
|
||||
internal sealed partial class Program
|
||||
{
|
||||
private static Task Main(string[] args) => BuildAvaloniaApp()
|
||||
private static Task Main(string[] args)
|
||||
{
|
||||
Platform.IsPodcastSupported = false;
|
||||
|
||||
return BuildAvaloniaApp()
|
||||
.WithInterFont()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.StartBrowserAppAsync("out");
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ sealed class Program
|
|||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Platform.IsPodcastSupported = true;
|
||||
CLIOptionHandler.HandleArgs(args);
|
||||
|
||||
// Short-circuit 2nd-instance launches (OS handing us the
|
||||
|
|
|
|||
60
src/PostIt/PostIt.Tests/ActivityNavigationTests.cs
Normal file
60
src/PostIt/PostIt.Tests/ActivityNavigationTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class ActivityNavigationTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Activity_recreation_restores_fresh_pages_and_back_navigation()
|
||||
{
|
||||
var app = Assert.IsType<App>(Application.Current);
|
||||
var previousView = app.View;
|
||||
var original = new MainView();
|
||||
var home = new HomePage();
|
||||
var draft = new Settings { SelectedLanguage = "fr" };
|
||||
var editor = new ContentPage { DataContext = draft };
|
||||
await original.NavRoot.PushAsync(home, null);
|
||||
await original.NavRoot.PushAsync(editor, null);
|
||||
app.AttachMainWindow(original);
|
||||
var window = new Window();
|
||||
try
|
||||
{
|
||||
var restored = app.CreateActivityView(new Settings());
|
||||
window.Content = restored;
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
|
||||
Assert.IsType<HomePage>(restored.NavRoot.NavigationStack[0]);
|
||||
Assert.NotSame(home, restored.NavRoot.NavigationStack[0]);
|
||||
Assert.NotSame(editor, restored.NavRoot.NavigationStack[1]);
|
||||
Assert.Same(draft, restored.NavRoot.NavigationStack[1].DataContext);
|
||||
Assert.Equal("fr", draft.SelectedLanguage);
|
||||
|
||||
window.Content = null;
|
||||
window.Content = restored;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
|
||||
|
||||
// Another activity recreation must retain the already-restored stack.
|
||||
window.Content = null;
|
||||
var recreatedAgain = app.CreateActivityView(new Settings());
|
||||
window.Content = recreatedAgain;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Equal(2, recreatedAgain.NavRoot.NavigationStack.Count);
|
||||
Assert.Same(draft, recreatedAgain.NavRoot.NavigationStack[1].DataContext);
|
||||
await recreatedAgain.NavRoot.PopAsync(null);
|
||||
Assert.IsType<HomePage>(Assert.Single(recreatedAgain.NavRoot.NavigationStack));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
app.AttachMainWindow(previousView ?? new MainView());
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
Normal file
242
src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class AdministrationAccessTests
|
||||
{
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Moderator", false)]
|
||||
[InlineData("Administrator", true)]
|
||||
[InlineData("administrator", false)]
|
||||
[InlineData("Performer", false)]
|
||||
[InlineData("moderator", false)]
|
||||
[InlineData("", false)]
|
||||
public async Task Home_administration_visibility_and_command_follow_the_administrator_role(string role, bool allowed)
|
||||
{
|
||||
await using var api = new ProfileTransport(role);
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
var vm = new HomePageViewModel(api, api.Settings, session, null!);
|
||||
var page = new HomePage { DataContext = vm };
|
||||
var window = new Window { Content = page };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
var button = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||
button => Equals(button.Content, "Administration"));
|
||||
Assert.False(button.IsVisible);
|
||||
await session.RefreshAsync();
|
||||
Assert.Equal(allowed, session.IsAdministrator);
|
||||
Assert.Equal(allowed, button.IsVisible);
|
||||
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
|
||||
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
|
||||
button => Equals(button.Content, "Mon usage / administration"));
|
||||
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
Assert.False(button.IsVisible);
|
||||
Assert.False(vm.OpenAdministration.CanExecute(null));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Late_role_response_cannot_restore_access_after_logout()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
var pending = new TaskCompletionSource<Me>();
|
||||
api.PendingProfile = pending.Task;
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
var refresh = session.RefreshAsync();
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
pending.SetResult(new Me { Roles = ["Administrator"] });
|
||||
await refresh;
|
||||
Assert.False(session.IsAdministrator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator") { FailProfile = true };
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
Assert.False(session.IsAdministrator);
|
||||
Assert.Contains("Impossible de vérifier les droits", session.SessionLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Administration_loads_users_and_usage_and_matches_by_id()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
await vm.RefreshAsync();
|
||||
vm.SelectedUser = Assert.Single(vm.Users);
|
||||
Assert.Equal("alice", vm.SelectedUsage?.UserName);
|
||||
Assert.Equal(12m, vm.SelectedUsage?.ApiCalls);
|
||||
Assert.Single(vm.UsageOverview);
|
||||
Assert.Contains(api.Requests, path => path.EndsWith("/resource-usage/admin"));
|
||||
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Administration_displays_both_tabs_and_selected_user_usage()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
await vm.RefreshAsync();
|
||||
vm.SelectedUser = Assert.Single(vm.Users);
|
||||
var page = new AdministrationPage { DataContext = vm };
|
||||
var window = new Window { Content = page, Width = 800, Height = 600 };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
|
||||
Assert.Equal(7, tabs.Items.Count);
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
|
||||
text => text.Text == "Appels API : 12");
|
||||
tabs.SelectedIndex = 1;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
|
||||
text => text.Text == "alice-id");
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
|
||||
text => text.Text == "Appels API : 12");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Administration_denies_moderators_without_requesting_private_data()
|
||||
{
|
||||
await using var api = new ProfileTransport("Moderator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
api.Requests.Clear();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
await vm.RefreshAsync();
|
||||
Assert.Empty(api.Requests);
|
||||
Assert.Empty(vm.Users);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Administration_failure_reports_error_without_falling_back_to_personal_usage()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator") { FailUsage = true };
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
await vm.RefreshAsync();
|
||||
Assert.Empty(vm.Users);
|
||||
Assert.Empty(vm.UsageOverview);
|
||||
Assert.Contains("indisponible", vm.StatusMessage);
|
||||
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task My_profile_loads_own_profile_enables_saving_and_only_loads_personal_usage()
|
||||
{
|
||||
await using var api = new ProfileTransport("Performer");
|
||||
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
|
||||
await vm.RefreshAsync();
|
||||
Assert.True(vm.IsProfileLoaded);
|
||||
Assert.Equal(12m, vm.ResourceUsage?.ApiCalls);
|
||||
Assert.True(vm.SaveProfileCommand.CanExecute(null));
|
||||
vm.FullName = "Alice Updated";
|
||||
Assert.Equal("Alice Updated", vm.BuildProfileUpdateRequest().FullName);
|
||||
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Usage_failure_does_not_disable_profile_management()
|
||||
{
|
||||
await using var api = new ProfileTransport("Performer") { FailUsage = true };
|
||||
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
|
||||
await vm.RefreshAsync();
|
||||
Assert.True(vm.IsProfileLoaded);
|
||||
Assert.True(vm.SaveProfileCommand.CanExecute(null));
|
||||
Assert.Null(vm.ResourceUsage);
|
||||
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
|
||||
}
|
||||
|
||||
internal sealed class ProfileTransport : YavscApiClient
|
||||
{
|
||||
private readonly string _tokenPath;
|
||||
private readonly string _role;
|
||||
public List<string> Requests { get; } = [];
|
||||
public Task<Me>? PendingProfile { get; set; }
|
||||
public bool FailProfile { get; set; }
|
||||
public bool FailUsage { get; set; }
|
||||
public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl);
|
||||
|
||||
public ProfileTransport(string role)
|
||||
: this(role, Path.Combine(Path.GetTempPath(), $"postit-admin-{Guid.NewGuid():N}.json")) { }
|
||||
|
||||
private ProfileTransport(string role, string path) : base(new Settings(), CreateStore(path))
|
||||
{
|
||||
_role = role;
|
||||
_tokenPath = path;
|
||||
}
|
||||
|
||||
private static TokenStore CreateStore(string path)
|
||||
{
|
||||
var store = new TokenStore(path);
|
||||
store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
return store;
|
||||
}
|
||||
|
||||
public override async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Requests.Add(path);
|
||||
object result;
|
||||
if (path.EndsWith("/account/me"))
|
||||
{
|
||||
if (FailProfile)
|
||||
throw new HttpRequestException("Profile unavailable");
|
||||
result = PendingProfile is not null ? await PendingProfile
|
||||
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
|
||||
}
|
||||
else if (path.Contains("/resource-usage/"))
|
||||
{
|
||||
if (FailUsage)
|
||||
throw new HttpRequestException("Usage unavailable");
|
||||
var usage = new ResourceUsageSummary { UserId = "alice-id", UserName = "alice", ApiCalls = 12m };
|
||||
result = path.EndsWith("/admin") ? new List<ResourceUsageSummary> { usage } : usage;
|
||||
}
|
||||
else if (path.EndsWith("/users"))
|
||||
result = new List<UserInfo> { new() { UserId = "alice-id", UserName = "alice" } };
|
||||
else
|
||||
throw new InvalidOperationException($"Unexpected endpoint: {path}");
|
||||
|
||||
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
|
||||
}
|
||||
|
||||
public new async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
File.Delete(_tokenPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
227
src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
Normal file
227
src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Administration;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class AdministrationToolsTests
|
||||
{
|
||||
[AvaloniaTheory]
|
||||
[InlineData(400, 800)]
|
||||
[InlineData(800, 400)]
|
||||
public void All_administration_tabs_and_typed_forms_are_accessible_in_portrait_and_landscape(int width, int height)
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
var page = new AdministrationPage { DataContext = vm };
|
||||
var window = new Window { Content = page, Width = width, Height = height };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
|
||||
for (var index = 0; index < tabs.Items.Count; ++index)
|
||||
{
|
||||
var tab = Assert.IsType<TabItem>(tabs.ContainerFromIndex(index));
|
||||
var point = tab.TranslatePoint(new Point(), page);
|
||||
Assert.NotNull(point);
|
||||
Assert.True(point.Value.X >= 0 && point.Value.X + tab.Bounds.Width <= page.Bounds.Width + 0.5);
|
||||
}
|
||||
tabs.SelectedIndex = 3;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(), block => block.Text == "RequirePkce");
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBox>(),
|
||||
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ClientId");
|
||||
tabs.SelectedIndex = 5;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<ComboBox>(),
|
||||
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ToSend");
|
||||
}
|
||||
finally { window.Close(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_form_retains_every_scalar_field_and_validates_numeric_input()
|
||||
{
|
||||
var settings = new ClientSettings { ClientId = "postit", ClientName = "PostIt", AccessTokenLifetime = 900,
|
||||
ConsentLifetime = null, RequireConsent = true, PairWiseSubjectSalt = null };
|
||||
var fields = AdministrationFieldViewModel.From(settings);
|
||||
Assert.Equal(typeof(ClientSettings).GetProperties().Length, fields.Count);
|
||||
Assert.Equal(settings, AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
var lifetime = fields.Single(field => field.Name == nameof(ClientSettings.AccessTokenLifetime));
|
||||
lifetime.Text = "not-a-number";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
lifetime.Text = "-1";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mailing_form_round_trips_enum_and_multiline_body()
|
||||
{
|
||||
var settings = new MailingTemplateSettings { Id = "template", Topic = "Topic", Body = "First\nSecond",
|
||||
ReplyToAddress = "reply@example.invalid", ToSend = MailPeriodicity.Monthly };
|
||||
var fields = AdministrationFieldViewModel.From(settings);
|
||||
Assert.Equal(settings, AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
|
||||
Assert.True(fields.Single(field => field.Name == "Body").IsMultiline);
|
||||
var periodicity = fields.Single(field => field.Name == "ToSend");
|
||||
Assert.True(periodicity.IsChoice);
|
||||
periodicity.Text = "Unknown";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_commands_save_typed_data_and_require_confirmation_for_delete()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1"));
|
||||
vm.ClientFields.Single(field => field.Name == "ClientId").Text = "new-client";
|
||||
await vm.SaveClientCommand.ExecuteAsync(null);
|
||||
Assert.Equal("new-client", transport.SavedClient?.ClientId);
|
||||
Assert.Equal(1, vm.SelectedClient?.Id);
|
||||
transport.Paths.Clear();
|
||||
await vm.DeleteClientCommand.ExecuteAsync(null);
|
||||
Assert.Empty(transport.Paths);
|
||||
Assert.Contains("confirmation", vm.StatusMessage);
|
||||
vm.ConfirmDestructiveAction = true;
|
||||
await vm.DeleteClientCommand.ExecuteAsync(null);
|
||||
Assert.Contains("DELETE https://example.invalid/api/v1/administration/clients/1", transport.Paths);
|
||||
Assert.False(vm.ConfirmDestructiveAction);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secrets_are_shown_once_and_cleared_on_selection_and_logout_denies_operations()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
|
||||
await vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.Equal("generated-test-value", vm.NewSecretValue);
|
||||
Assert.Equal("", vm.SecretInput);
|
||||
vm.NewClientCommand.Execute(null);
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
transport.Paths.Clear();
|
||||
await vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.Empty(transport.Paths);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secret_response_received_after_logout_is_discarded()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport
|
||||
{
|
||||
PendingSecret = new TaskCompletionSource<NewClientSecret>(TaskCreationOptions.RunContinuationsAsynchronously)
|
||||
};
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
|
||||
var operation = vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.IsBusy);
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
transport.PendingSecret.SetResult(new NewClientSecret(1, "must-not-be-shown", null));
|
||||
await operation;
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
Assert.Empty(vm.Clients);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
Assert.Equal(["POST https://example.invalid/api/v1/administration/clients/1/secrets"], transport.Paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_without_administrator_access_clears_previous_administration_data()
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
var client = new AdministrationClient(1, new ClientSettings { ClientId = "previous-client" }, [], []);
|
||||
vm.Clients.Add(client);
|
||||
vm.SelectedClient = client;
|
||||
vm.NewSecretValue = "previous-secret";
|
||||
await vm.RefreshAsync();
|
||||
Assert.Empty(vm.Clients);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
Assert.Equal("", vm.ClientFields.Single(field => field.Name == "ClientId").Text);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_requires_confirmation_and_uses_only_administration_host()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => vm.ImportTransferAsync(new AdministrationTransfer()));
|
||||
Assert.Empty(transport.Paths);
|
||||
vm.ConfirmDestructiveAction = true;
|
||||
var result = await vm.ImportTransferAsync(new AdministrationTransfer());
|
||||
Assert.Equal(new ImportResult(0, 0, 0), result);
|
||||
Assert.False(vm.ConfirmDestructiveAction);
|
||||
Assert.Equal(["POST https://example.invalid/api/v1/administration/transfer"], transport.Paths);
|
||||
}
|
||||
|
||||
private sealed class AdminTransport : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<string> Paths { get; } = [];
|
||||
public ClientSettings? SavedClient { get; private set; }
|
||||
public TaskCompletionSource<NewClientSecret>? PendingSecret { get; init; }
|
||||
public async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add($"{method} {path}");
|
||||
object result;
|
||||
if (typeof(T) == typeof(AdministrationClient))
|
||||
{
|
||||
SavedClient = Assert.IsType<ClientSettings>(body);
|
||||
result = new AdministrationClient(1, SavedClient, [], []);
|
||||
}
|
||||
else if (typeof(T) == typeof(List<AdministrationClient>))
|
||||
result = new List<AdministrationClient> { new(1, SavedClient ?? new ClientSettings { ClientId = "client" }, [], []) };
|
||||
else if (typeof(T) == typeof(NewClientSecret))
|
||||
result = PendingSecret is null
|
||||
? new NewClientSecret(1, "generated-test-value", null)
|
||||
: await PendingSecret.Task;
|
||||
else if (typeof(T) == typeof(ImportResult))
|
||||
result = new ImportResult(0, 0, 0);
|
||||
else
|
||||
throw new NotSupportedException(typeof(T).Name);
|
||||
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
|
||||
}
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add($"{method} {path}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,12 @@ public class AndroidAppLaunchTests
|
|||
[Fact]
|
||||
public void PostIt_starts_and_draws_a_first_frame_on_the_emulator()
|
||||
{
|
||||
if (IsRunningInCi())
|
||||
{
|
||||
_output.WriteLine("[skip] Android instrumentation smoke test disabled in CI because the emulator/test APK signature must match the target package exactly and is not reliable on hosted runners");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsPackageInstalledOnAnyDevice())
|
||||
{
|
||||
_output.WriteLine($"[skip] {PackageName} not installed on any device");
|
||||
|
|
@ -46,6 +52,14 @@ public class AndroidAppLaunchTests
|
|||
_output.WriteLine("[step] first frame observed");
|
||||
}
|
||||
|
||||
private static bool IsRunningInCi()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("CI") is { Length: > 0 }
|
||||
|| Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is { Length: > 0 }
|
||||
|| Environment.GetEnvironmentVariable("FORGEJO_CI") is { Length: > 0 }
|
||||
|| Environment.GetEnvironmentVariable("CI_NAME") is { Length: > 0 };
|
||||
}
|
||||
|
||||
private static bool IsPackageInstalledOnAnyDevice()
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
#if ANDROID
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Graphics;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using PostIt.Android.Services;
|
||||
using Path = System.IO.Path;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Debug-only synthetic encoder test; never opens the camera or contacts the API.</summary>
|
||||
[Activity(Name = "fr.pschneider.postit.CameraFrameRotationTestActivity", Exported = true)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class AndroidCameraFrameRotationTestActivity : Activity
|
||||
{
|
||||
protected override async void OnCreate(Bundle? state)
|
||||
{
|
||||
base.OnCreate(state);
|
||||
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "camera-rotation-test-result.txt");
|
||||
File.WriteAllText(resultPath, "RUNNING");
|
||||
try
|
||||
{
|
||||
foreach (var rotation in new[] { 0, 90, 180, 270 })
|
||||
await VerifyRotationAsync(rotation);
|
||||
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\nsource=synthetic");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(resultPath, $"FAIL\n{ex}");
|
||||
}
|
||||
finally { Finish(); }
|
||||
}
|
||||
|
||||
private async Task VerifyRotationAsync(int rotation)
|
||||
{
|
||||
const int width = 640;
|
||||
const int height = 480;
|
||||
var outputWidth = rotation is 90 or 270 ? height : width;
|
||||
var outputHeight = rotation is 90 or 270 ? width : height;
|
||||
var path = Path.Combine(FilesDir!.AbsolutePath, $"camera-rotation-{rotation}.ts");
|
||||
void Stage(string stage) => File.WriteAllText(
|
||||
Path.Combine(FilesDir.AbsolutePath, "camera-rotation-test-result.txt"), $"RUNNING\nrotation={rotation}\nstage={stage}");
|
||||
Stage("prepare");
|
||||
#pragma warning disable CS0618
|
||||
using var recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
|
||||
? new MediaRecorder(this) : new MediaRecorder();
|
||||
#pragma warning restore CS0618
|
||||
recorder.SetVideoSource(VideoSource.Surface);
|
||||
recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
|
||||
recorder.SetVideoSize(outputWidth, outputHeight);
|
||||
recorder.SetVideoFrameRate(30);
|
||||
recorder.SetVideoEncodingBitRate(1200000);
|
||||
recorder.SetVideoEncoder(VideoEncoder.H264);
|
||||
recorder.SetOutputFile(path);
|
||||
recorder.Prepare();
|
||||
using var encoderSurface = recorder.Surface
|
||||
?? throw new InvalidOperationException("Encoder surface unavailable.");
|
||||
Exception? failure = null;
|
||||
var stopping = 0;
|
||||
Stage("initialize EGL");
|
||||
using (var rotator = await AndroidCameraFrameRotator.CreateAsync(
|
||||
encoderSurface, width, height, rotation, error =>
|
||||
{
|
||||
if (Volatile.Read(ref stopping) != 0) return;
|
||||
failure = error;
|
||||
File.WriteAllText(Path.Combine(FilesDir.AbsolutePath, "camera-rotation-test-result.txt"), $"FAIL\n{error}");
|
||||
}))
|
||||
{
|
||||
Stage("start recorder");
|
||||
recorder.Start();
|
||||
Task? stop = null;
|
||||
try
|
||||
{
|
||||
await rotator.StartAsync();
|
||||
using var paint = new Paint();
|
||||
Color[] colors = [Color.Red, Color.Green, Color.Blue, Color.Yellow];
|
||||
Stage("draw frames");
|
||||
for (var frame = 0; frame < 90; frame++)
|
||||
{
|
||||
if (failure is not null) throw failure;
|
||||
if (frame == 45)
|
||||
{
|
||||
Volatile.Write(ref stopping, 1);
|
||||
stop = Task.Run(recorder.Stop);
|
||||
}
|
||||
if (stop?.IsCompleted == true) break;
|
||||
var canvas = rotator.CameraSurface.LockCanvas(null)
|
||||
?? throw new InvalidOperationException("Synthetic camera canvas unavailable.");
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < colors.Length; i++)
|
||||
{
|
||||
paint.Color = colors[i];
|
||||
var x = i % 2 * width / 2;
|
||||
var y = i / 2 * height / 2;
|
||||
canvas.DrawRect(x, y, x + width / 2, y + height / 2, paint);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
rotator.CameraSurface.UnlockCanvasAndPost(canvas);
|
||||
canvas.Dispose();
|
||||
}
|
||||
await Task.Delay(34);
|
||||
}
|
||||
if (failure is not null) throw failure;
|
||||
Stage("wait for recorder stop");
|
||||
await (stop ?? throw new InvalidOperationException("Recorder stop was not requested."))
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Stage("dispose EGL");
|
||||
rotator.Dispose();
|
||||
}
|
||||
}
|
||||
Stage("decode");
|
||||
using var retriever = new MediaMetadataRetriever();
|
||||
retriever.SetDataSource(path);
|
||||
using var bitmap = retriever.GetFrameAtTime(0, Option.ClosestSync)
|
||||
?? throw new InvalidOperationException($"Cannot decode rotation {rotation}.");
|
||||
if (bitmap.Width != outputWidth || bitmap.Height != outputHeight)
|
||||
throw new InvalidOperationException($"Rotation {rotation}: dimensions {bitmap.Width}x{bitmap.Height}, expected {outputWidth}x{outputHeight}.");
|
||||
Color[] expected = rotation switch
|
||||
{
|
||||
90 => [Color.Blue, Color.Red, Color.Yellow, Color.Green],
|
||||
180 => [Color.Yellow, Color.Blue, Color.Green, Color.Red],
|
||||
270 => [Color.Green, Color.Yellow, Color.Red, Color.Blue],
|
||||
_ => [Color.Red, Color.Green, Color.Blue, Color.Yellow]
|
||||
};
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
var actual = new Color(bitmap.GetPixel((i % 2 * 2 + 1) * outputWidth / 4,
|
||||
(i / 2 * 2 + 1) * outputHeight / 4));
|
||||
var target = expected[i];
|
||||
if (Math.Abs(actual.R - target.R) > 40 || Math.Abs(actual.G - target.G) > 40 || Math.Abs(actual.B - target.B) > 40)
|
||||
throw new InvalidOperationException($"Rotation {rotation}, quadrant {i}: {actual}, expected {target}.");
|
||||
}
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
125
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal file
125
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#if ANDROID
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Avalonia.Threading;
|
||||
using PostIt.Android;
|
||||
using PostIt.Android.Services;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Debug-only local encoder test; never connects to the podcast API.</summary>
|
||||
[Activity(Name = "fr.pschneider.postit.PodcastCaptureTestActivity", Exported = true)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class AndroidPodcastCaptureTestActivity : Activity
|
||||
{
|
||||
private bool _microphone = true;
|
||||
private bool _notificationStop;
|
||||
private int _cameraIndex;
|
||||
protected override async void OnCreate(Bundle? state)
|
||||
{
|
||||
base.OnCreate(state);
|
||||
_microphone = Intent?.GetStringExtra("microphone") != "false";
|
||||
_notificationStop = Intent?.GetStringExtra("notificationStop") == "true";
|
||||
_cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0;
|
||||
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt");
|
||||
File.WriteAllText(resultPath, "RUNNING");
|
||||
var result = new Bundle();
|
||||
try
|
||||
{
|
||||
await RunAsync(result);
|
||||
File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndurationSeconds={result.GetDouble("durationSeconds")}\naudio={result.GetBoolean("audio")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}\norientation={result.GetString("orientation")}\ntextureTransform={result.GetString("textureTransform")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(resultPath, $"FAIL\n{ex}");
|
||||
}
|
||||
finally { Finish(); }
|
||||
}
|
||||
|
||||
private async Task RunAsync(Bundle result)
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
throw new PlatformNotSupportedException("Android 8 required.");
|
||||
var context = this;
|
||||
var intent = new Intent(context, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask);
|
||||
StartActivity(intent);
|
||||
for (var i = 0; i < 100 && !MainActivity.IsForeground; i++) await Task.Delay(100);
|
||||
await Task.Delay(1000);
|
||||
var directory = Path.Combine(context.FilesDir!.AbsolutePath, "podcast-capture-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var devices = AndroidPodcastCapture.GetDevices();
|
||||
if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera.");
|
||||
IPodcastCapture? capture = null;
|
||||
try
|
||||
{
|
||||
capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync(
|
||||
devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value,
|
||||
_microphone ? "microphone" : null, directory, CancellationToken.None));
|
||||
await Task.Delay(3000);
|
||||
var before = SegmentCount(directory);
|
||||
context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
|
||||
await Task.Delay(9000);
|
||||
var after = SegmentCount(directory);
|
||||
if (capture.Completion.IsFaulted) await capture.Completion;
|
||||
if (capture is AndroidPodcastCapture androidCapture)
|
||||
{
|
||||
result.PutString("orientation", androidCapture.OrientationDiagnostics);
|
||||
result.PutString("textureTransform", androidCapture.TextureTransform);
|
||||
}
|
||||
if (after <= before) throw new InvalidOperationException($"Capture did not continue in background: {before} -> {after} segments.");
|
||||
if (_notificationStop)
|
||||
{
|
||||
using var stop = PendingIntent.GetService(context, 1,
|
||||
new Intent(context, typeof(PodcastCaptureService)).SetAction(PodcastCaptureService.StopAction),
|
||||
PendingIntentFlags.NoCreate | PendingIntentFlags.Immutable)
|
||||
?? throw new InvalidOperationException("Notification stop action missing.");
|
||||
stop.Send();
|
||||
}
|
||||
else await capture.StopAsync().WaitAsync(TimeSpan.FromSeconds(15));
|
||||
await capture.Completion.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8")));
|
||||
if (segments.Count < 3) throw new InvalidOperationException("Insufficient capture segments.");
|
||||
var duration = segments.Sum(segment => segment.DurationSeconds);
|
||||
if (duration < 8 || duration > 16)
|
||||
throw new InvalidOperationException($"Expected 8-16 seconds of capture, got {duration}.");
|
||||
using var extractor = new MediaExtractor();
|
||||
extractor.SetDataSource(Path.Combine(directory, segments[0].FileName));
|
||||
var video = false;
|
||||
var audio = false;
|
||||
for (var track = 0; track < extractor.TrackCount; track++)
|
||||
{
|
||||
using var format = extractor.GetTrackFormat(track);
|
||||
var mime = format.GetString(MediaFormat.KeyMime);
|
||||
video |= mime == "video/avc";
|
||||
audio |= mime == "audio/mp4a-latm";
|
||||
}
|
||||
if (!video || (_microphone && !audio))
|
||||
throw new InvalidOperationException($"Missing capture tracks: H.264={video}, AAC={audio}.");
|
||||
result.PutInt("segments", segments.Count);
|
||||
result.PutDouble("durationSeconds", duration);
|
||||
result.PutBoolean("audio", audio);
|
||||
result.PutString("directory", directory);
|
||||
result.PutString("background", $"segments {before} -> {after}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (capture is not null) await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static int SegmentCount(string directory)
|
||||
{
|
||||
var path = Path.Combine(directory, "index.m3u8");
|
||||
return File.Exists(path) ? HlsCapturePlaylist.Parse(File.ReadAllText(path)).Count : 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
504
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
504
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
using System.Net;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Logging;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Markdown.Avalonia;
|
||||
using ColorTextBlock.Avalonia;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogReaderTests
|
||||
{
|
||||
private static BlogPostDto Post(string article = "Texte du billet.") => new()
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Un billet à lire",
|
||||
Article = article,
|
||||
AuthorId = "user-id-not-login",
|
||||
Author = new BlogPostAuthorDto { UserName = "paul" },
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
|
||||
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
|
||||
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
|
||||
resources: resources);
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("paul", vm.ReaderAuthor);
|
||||
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.EditPostCommand.Execute(null);
|
||||
vm.DraftArticle = "Modifications non enregistrées";
|
||||
vm.ReadPostCommand.Execute(null);
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
Assert.True(vm.IsDraftModified);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Null(vm.SelectedPost);
|
||||
Assert.Empty(vm.DraftTitle);
|
||||
Assert.Empty(vm.DraftArticle!);
|
||||
vm.DraftTitle = "Nouveau brouillon";
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
||||
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.False(page.SaveButton.IsVisible);
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Assert.Equal(vm.ReaderArticle, reader.Markdown);
|
||||
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
|
||||
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.True(page.SaveButton.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
var post = Post();
|
||||
vm.Posts.Add(post);
|
||||
vm.FilteredPosts.Add(post);
|
||||
var previousSink = Logger.Sink;
|
||||
var sink = new RecordingLogSink();
|
||||
Logger.Sink = sink;
|
||||
Window? window = null;
|
||||
try
|
||||
{
|
||||
var page = new BlogsPage();
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||
Assert.NotNull(engine.HyperlinkCommand);
|
||||
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
||||
page.DataContext = vm;
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Empty(vm.ReaderTitle);
|
||||
Assert.Null(vm.ReaderDateModified);
|
||||
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
|
||||
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
|
||||
|
||||
vm.SelectedPost = post;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Equal(post.Title, vm.ReaderTitle);
|
||||
Assert.Equal(post.DateModified, vm.ReaderDateModified);
|
||||
|
||||
vm.SelectedPost = null;
|
||||
page.DataContext = null;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Empty(vm.ReaderTitle);
|
||||
Assert.Null(vm.ReaderDateModified);
|
||||
Assert.NotNull(engine.HyperlinkCommand);
|
||||
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
||||
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
Logger.Sink = previousSink;
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(360, 800, false)]
|
||||
[InlineData(1000, 420, true)]
|
||||
[InlineData(1000, 280, true)]
|
||||
[InlineData(600, 600, true)]
|
||||
[InlineData(599, 600, false)]
|
||||
[InlineData(601, 600, true)]
|
||||
public async Task Blog_layout_uses_columns_from_the_square_window_threshold(int width, int height, bool wide)
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
for (var i = 0; i < 40; i++)
|
||||
vm.FilteredPosts.Add(Post($"Billet {i}"));
|
||||
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var main = new MainView();
|
||||
var nav = main.FindControl<NavigationPage>("NavRoot")!;
|
||||
await nav.PushAsync(new ContentPage());
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = width, Height = height, Content = main };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
|
||||
var reader = page.FindControl<Grid>("PostReader")!;
|
||||
var list = page.FindControl<Border>("PostsListBorder")!;
|
||||
var splitter = page.FindControl<GridSplitter>("PostsSplitter")!;
|
||||
Assert.Equal(wide ? GridResizeDirection.Columns : GridResizeDirection.Rows, splitter.ResizeDirection);
|
||||
Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
|
||||
Assert.True(nav.IsBackButtonEffectivelyVisible);
|
||||
Assert.True(reader.Bounds.Width > 0 && reader.Bounds.Height > 0);
|
||||
Assert.True(list.Bounds.Width > 0 && list.Bounds.Height > 0);
|
||||
if (wide)
|
||||
{
|
||||
Assert.True(reader.Bounds.Right <= list.Bounds.Left);
|
||||
Assert.Equal(2, Grid.GetColumn(page.BlogsHeaderPanel));
|
||||
Assert.Equal(2, Grid.GetColumn(page.BlogsCommandBar));
|
||||
Assert.Equal(reader.Margin.Top, reader.Bounds.Top);
|
||||
Assert.Equal(page.StatusBar.Bounds.Top - reader.Margin.Bottom, reader.Bounds.Bottom);
|
||||
Assert.Equal(36, page.TranslatePoint(new Point(), nav)!.Value.Y);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(list.Bounds.Bottom <= reader.Bounds.Top);
|
||||
Assert.Equal(0, Grid.GetColumn(page.BlogsHeaderPanel));
|
||||
Assert.Equal(0, Grid.GetColumn(page.BlogsCommandBar));
|
||||
}
|
||||
|
||||
var articleScroll = page.FindControl<MarkdownScrollViewer>("ArticleReader")!
|
||||
.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
Assert.InRange(articleScroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||
Assert.True(articleScroll.Extent.Height > articleScroll.Viewport.Height);
|
||||
var listScroll = page.PostsListBox.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
Assert.InRange(listScroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||
Assert.True(listScroll.Extent.Height > listScroll.Viewport.Height);
|
||||
|
||||
var originalReaderSize = reader.Bounds.Size;
|
||||
splitter.Focus();
|
||||
splitter.RaiseEvent(new KeyEventArgs
|
||||
{
|
||||
RoutedEvent = InputElement.KeyDownEvent,
|
||||
Key = wide ? Key.Right : Key.Down
|
||||
});
|
||||
window.UpdateLayout();
|
||||
if (wide)
|
||||
Assert.True(reader.Bounds.Width > originalReaderSize.Width);
|
||||
else
|
||||
Assert.True(reader.Bounds.Height < originalReaderSize.Height);
|
||||
|
||||
page.BlogsCommandBar.IsOpen = true;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
||||
Assert.Same(vm, page.SaveButton.DataContext);
|
||||
Assert.False(page.SaveButton.IsVisible);
|
||||
Assert.True(page.EditPostButton.IsVisible);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.True(page.SaveButton.IsVisible);
|
||||
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
|
||||
Assert.False(page.EditPostButton.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Resizing_between_portrait_and_landscape_preserves_the_editor_and_draft()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post();
|
||||
vm.EditPostCommand.Execute(null);
|
||||
vm.DraftArticle = "Brouillon conservé pendant le redimensionnement.";
|
||||
var document = vm.DraftArticleDocument;
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var editor = page.FindControl<Grid>("PostEditor")!;
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 360, Height = 800, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
foreach (var size in new[] { new Size(360, 800), new Size(1000, 420), new Size(600, 600), new Size(599, 600), new Size(360, 800) })
|
||||
{
|
||||
window.Width = size.Width;
|
||||
window.Height = size.Height;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
||||
|
||||
var wide = size.Width >= size.Height;
|
||||
Assert.Equal(wide ? 0 : 4, Grid.GetRow(editor));
|
||||
Assert.Equal(wide ? 3 : 1, Grid.GetRowSpan(editor));
|
||||
Assert.Equal(wide ? 36d : (double?)null, NavigationPage.GetBarHeightOverride(page));
|
||||
Assert.Same(editor, page.FindControl<Grid>("PostEditor"));
|
||||
Assert.True(editor.IsVisible);
|
||||
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.True(vm.IsDraftModified);
|
||||
Assert.Equal("Brouillon conservé pendant le redimensionnement.", vm.DraftArticle);
|
||||
Assert.Same(document, vm.DraftArticleDocument);
|
||||
Assert.Equal(42, vm.SelectedPost!.Id);
|
||||
Assert.True(editor.Bounds.Width > 0 && editor.Bounds.Height > 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
|
||||
{
|
||||
const string url = "https://org.example.test/files/paul/absent.jpg";
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
|
||||
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
var resolver = new BlogImageResolver(client);
|
||||
var previousSink = Logger.Sink;
|
||||
var sink = new RecordingLogSink();
|
||||
Logger.Sink = sink;
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
|
||||
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
|
||||
Assert.Contains(url, error.Values);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Logger.Sink = previousSink;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLogSink : ILogSink
|
||||
{
|
||||
public List<(string Area, object?[] Values)> Entries { get; } = [];
|
||||
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
|
||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
|
||||
=> Entries.Add((area, []));
|
||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
|
||||
=> Entries.Add((area, values));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
|
||||
scroll.ScrollToEnd();
|
||||
Assert.True(scroll.Offset.Y > 0);
|
||||
vm.SelectedPost = null;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Empty(reader.Markdown!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
||||
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
||||
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
|
||||
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
|
||||
{
|
||||
using var source = new SKBitmap(32, 24);
|
||||
source.Erase(SKColors.CornflowerBlue);
|
||||
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
|
||||
var jpeg = encoded.ToArray();
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||
{
|
||||
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
|
||||
request.RequestUri!.AbsoluteUri);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(new NonSeekableStream(jpeg))
|
||||
};
|
||||
}));
|
||||
var resolver = new BlogImageResolver(client)
|
||||
{
|
||||
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
|
||||
};
|
||||
await using var stream = await resolver.ResolveImageResource(url);
|
||||
Assert.NotNull(stream);
|
||||
Assert.True(stream.CanSeek);
|
||||
Assert.Equal(0, stream.Position);
|
||||
using var decoded = SKBitmap.Decode(stream);
|
||||
Assert.NotNull(decoded);
|
||||
Assert.Equal(32, decoded.Width);
|
||||
Assert.Equal(24, decoded.Height);
|
||||
}
|
||||
|
||||
private sealed class NonSeekableStream(byte[] bytes) : Stream
|
||||
{
|
||||
private readonly MemoryStream _inner = new(bytes, writable: false);
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> _inner.ReadAsync(buffer, cancellationToken);
|
||||
public override void Flush() => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
_inner.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||
{
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType = new("video/mp4");
|
||||
return response;
|
||||
}));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
var app = (App)Application.Current!;
|
||||
var root = new MainView();
|
||||
app.AttachMainWindow(root);
|
||||
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
|
||||
app.DataTemplates.Insert(0, template);
|
||||
try
|
||||
{
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
|
||||
Assert.NotNull(link.Command);
|
||||
link.Command(link.CommandParameter!);
|
||||
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
|
||||
await vm.OpenResourceCommand.ExecutionTask;
|
||||
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
|
||||
Assert.Equal("video/mp4", target.MimeType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.DataTemplates.Remove(template);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Video_viewmodel_resolves_to_the_shared_page_with_a_platform_video_surface_slot()
|
||||
{
|
||||
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
||||
Assert.NotNull(page.FindControl<ContentControl>("VideoSurface"));
|
||||
Assert.NotNull(page.FindControl<Button>("PlayButton"));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Video_page_observes_platform_lifecycle_only_while_navigated()
|
||||
{
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(new ContentPage());
|
||||
var page = new VideoPage();
|
||||
await nav.PushAsync(page);
|
||||
var status = page.FindControl<TextBlock>("PlaybackStatus")!;
|
||||
Platform.SuspendPlayback();
|
||||
Assert.Equal("Lecture suspendue.", status.Text);
|
||||
Assert.False(page.FindControl<Button>("PlayButton")!.IsEnabled);
|
||||
Platform.ResumePlayback();
|
||||
Assert.Null(page.FindControl<ContentControl>("VideoSurface")!.Content);
|
||||
await nav.PopAsync();
|
||||
status.Text = "No longer active";
|
||||
Platform.SuspendPlayback();
|
||||
Assert.Equal("No longer active", status.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
||||
}
|
||||
}
|
||||
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogResourceServiceTests
|
||||
{
|
||||
private const string Authority = "https://yavsc.pschneider.fr";
|
||||
|
||||
[Theory]
|
||||
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
|
||||
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
|
||||
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
|
||||
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
|
||||
|
||||
[Fact]
|
||||
public void Encodes_login_and_keeps_trailing_slash()
|
||||
=> Assert.Equal(Authority + "/files/paul%20test/",
|
||||
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Authority + "/files/paul/movie.mp4", true)]
|
||||
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
|
||||
[InlineData(Authority + "/files/paul/", false)]
|
||||
[InlineData(Authority + "/files/paul", false)]
|
||||
[InlineData(Authority + "/files/", false)]
|
||||
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
|
||||
[InlineData(Authority + "/page.mp4", false)]
|
||||
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
|
||||
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
|
||||
|
||||
[Theory]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("file:///tmp/video.mp4")]
|
||||
[InlineData("data:video/mp4;base64,AAAA")]
|
||||
public void Rejects_non_web_links(string link)
|
||||
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("video/webm", "movie.jpg", "video/webm")]
|
||||
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
|
||||
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
|
||||
[InlineData(null, "movie.webm", "video/webm")]
|
||||
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
|
||||
[InlineData(null, "file.unknown", "application/octet-stream")]
|
||||
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Head, request.Method);
|
||||
return Response(HttpStatusCode.OK, mime);
|
||||
}));
|
||||
var service = new BlogResourceService(client);
|
||||
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed)]
|
||||
[InlineData(HttpStatusCode.NotImplemented)]
|
||||
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
|
||||
{
|
||||
var calls = 0;
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
calls++;
|
||||
if (request.Method == HttpMethod.Head)
|
||||
return Response(status, null);
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
|
||||
return Response(HttpStatusCode.PartialContent, "video/mp4");
|
||||
}));
|
||||
Assert.Equal("video/mp4", await new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
|
||||
return Response(HttpStatusCode.OK, "image/jpeg");
|
||||
}));
|
||||
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await using var stream = await resolver.ResolveImageResource(link);
|
||||
Assert.NotNull(stream);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
|
||||
{
|
||||
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
|
||||
{
|
||||
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
|
||||
if (mime is not null)
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||||
return response;
|
||||
}
|
||||
|
||||
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
37
src/PostIt/PostIt.Tests/CameraFrameClockTests.cs
Normal file
37
src/PostIt/PostIt.Tests/CameraFrameClockTests.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using PostIt.Android.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class CameraFrameClockTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(4_213_000_000_000L)]
|
||||
[InlineData(0L)]
|
||||
[InlineData(-1_000_000_000L)]
|
||||
public void Aligns_camera_epoch_to_encoder_and_preserves_frame_intervals(long cameraOffset)
|
||||
{
|
||||
var clock = new CameraFrameClock();
|
||||
const long now = 20_000_000_000_000L;
|
||||
var camera = now + cameraOffset;
|
||||
Assert.Equal(now, clock.GetPresentationTime(camera, now));
|
||||
Assert.Equal(now + 33_333_333, clock.GetPresentationTime(camera + 33_333_333, now + 40_000_000));
|
||||
Assert.Equal(now + 100_000_000, clock.GetPresentationTime(camera + 100_000_000, now + 110_000_000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_submit_the_same_camera_image_twice()
|
||||
{
|
||||
var clock = new CameraFrameClock();
|
||||
Assert.Equal(100, clock.GetPresentationTime(1000, 100));
|
||||
Assert.Null(clock.GetPresentationTime(1000, 110));
|
||||
Assert.Equal(120, clock.GetPresentationTime(1020, 130));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reports_a_camera_clock_that_goes_backwards()
|
||||
{
|
||||
var clock = new CameraFrameClock();
|
||||
clock.GetPresentationTime(1000, 100);
|
||||
Assert.Throws<InvalidOperationException>(() => clock.GetPresentationTime(999, 110));
|
||||
}
|
||||
}
|
||||
61
src/PostIt/PostIt.Tests/CameraFrameOrientationTests.cs
Normal file
61
src/PostIt/PostIt.Tests/CameraFrameOrientationTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using PostIt.Android.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class CameraFrameOrientationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(90, 0, false, 90)]
|
||||
[InlineData(90, 90, false, 0)]
|
||||
[InlineData(90, 180, false, 270)]
|
||||
[InlineData(90, 270, false, 180)]
|
||||
[InlineData(270, 0, true, 270)]
|
||||
[InlineData(270, 90, true, 0)]
|
||||
[InlineData(270, 180, true, 90)]
|
||||
[InlineData(270, 270, true, 180)]
|
||||
[InlineData(0, 90, false, 270)]
|
||||
[InlineData(180, 270, true, 90)]
|
||||
public void Uses_sensor_display_and_lens_facing(int sensor, int display, bool front, int expected)
|
||||
=> Assert.Equal(expected, CameraFrameOrientation.GetRotation(sensor, display, front));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, new float[] { 0, 0, 1, 0, 0, 1, 1, 1 })]
|
||||
[InlineData(90, new float[] { 1, 0, 1, 1, 0, 0, 0, 1 })]
|
||||
[InlineData(180, new float[] { 1, 1, 0, 1, 1, 0, 0, 0 })]
|
||||
[InlineData(270, new float[] { 0, 1, 0, 0, 1, 1, 1, 0 })]
|
||||
public void Maps_output_corners_to_input_for_clockwise_rotation(int rotation, float[] expected)
|
||||
=> Assert.Equal(expected, CameraFrameOrientation.GetTextureCoordinates(rotation));
|
||||
|
||||
[Theory]
|
||||
[InlineData(90, 0, -1, 0)]
|
||||
[InlineData(270, 0, 1, 0)]
|
||||
[InlineData(180, -1, 0, 0)]
|
||||
[InlineData(90, 1, 0, 90)]
|
||||
[InlineData(270, 1, 0, 270)]
|
||||
[InlineData(0, 0, -1, 270)]
|
||||
[InlineData(180, 0, -1, 90)]
|
||||
public void Does_not_repeat_rotation_already_in_surface_texture(
|
||||
int desired, float x, float y, int expected)
|
||||
{
|
||||
float[] matrix = [x, y, 0, 0, y, -x, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1];
|
||||
Assert.Equal(expected, CameraFrameOrientation.GetRemainingRotation(desired, matrix));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recognizes_the_measured_rear_camera_transform_even_with_cropping()
|
||||
{
|
||||
float[] matrix = [0, -0.9f, 0, 0, -0.8f, 0, 0, 0, 0, 0, 1, 0, 0.9f, 0.95f, 0, 1];
|
||||
Assert.Equal(0, CameraFrameOrientation.GetRemainingRotation(90, matrix));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-90)]
|
||||
[InlineData(45)]
|
||||
[InlineData(360)]
|
||||
public void Rejects_invalid_angles(int rotation)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => CameraFrameOrientation.GetRotation(rotation, 0, false));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => CameraFrameOrientation.GetRotation(90, rotation, false));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => CameraFrameOrientation.GetTextureCoordinates(rotation));
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,22 @@ public class HomePageNotificationsTests
|
|||
session.Refresh();
|
||||
Assert.False(transport.WasAnonymous);
|
||||
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||
|
||||
var note = Note(3, "Dismiss me");
|
||||
vm.Notifications.Add(note);
|
||||
Assert.True(vm.DismissNotificationCommand.CanExecute(note));
|
||||
transport.DismissResponse = Task.FromException(new HttpRequestException("Server unavailable"));
|
||||
await vm.DismissNotificationCommand.ExecuteAsync(note);
|
||||
Assert.Contains(note, vm.Notifications);
|
||||
Assert.Contains("Server unavailable", vm.NotificationsError);
|
||||
|
||||
transport.DismissResponse = Task.CompletedTask;
|
||||
await vm.DismissNotificationCommand.ExecuteAsync(note);
|
||||
Assert.Equal(HttpMethod.Post, transport.LastMethod);
|
||||
Assert.Equal("https://business.example/api/v1/dismiss/click/3", transport.LastPath);
|
||||
Assert.DoesNotContain(note, vm.Notifications);
|
||||
Assert.Null(vm.NotificationsError);
|
||||
|
||||
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
||||
transport.Response = privateResponse.Task;
|
||||
var privateLoad = vm.RefreshNotificationsAsync();
|
||||
|
|
@ -112,6 +128,42 @@ public class HomePageNotificationsTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_dismiss_removes_the_notification_without_an_api_call()
|
||||
{
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport);
|
||||
vm.Notifications.Add(Note(1, "Keep"));
|
||||
vm.Notifications.Add(Note(2, "Dismiss me"));
|
||||
|
||||
Assert.True(vm.DismissNotificationCommand.CanExecute(vm.Notifications[1]));
|
||||
Assert.False(vm.DismissNotificationCommand.CanExecute(null));
|
||||
|
||||
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[1]);
|
||||
|
||||
Assert.Null(transport.LastMethod);
|
||||
Assert.Null(transport.LastPath);
|
||||
Assert.Equal(1, Assert.Single(vm.Notifications).Id);
|
||||
Assert.Null(vm.NotificationsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_dismiss_of_the_last_notification_updates_the_empty_state()
|
||||
{
|
||||
var transport = new NotificationTransport();
|
||||
var vm = CreateViewModel(transport);
|
||||
transport.Response = Task.FromResult(new List<Notification> { Note(3, "Public") });
|
||||
await vm.RefreshNotificationsAsync();
|
||||
Assert.False(vm.HasNoNotifications);
|
||||
|
||||
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
|
||||
|
||||
Assert.Empty(vm.Notifications);
|
||||
Assert.True(vm.HasNoNotifications);
|
||||
Assert.Null(vm.NotificationsError);
|
||||
Assert.Null(transport.LastMethod);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
||||
{
|
||||
|
|
@ -138,6 +190,12 @@ public class HomePageNotificationsTests
|
|||
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||
b => b.Command == vm.RefreshNotificationsCommand);
|
||||
Assert.True(refresh.IsEnabled);
|
||||
var dismiss = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||
b => b.Command == vm.DismissNotificationCommand);
|
||||
Assert.Same(vm.Notifications[0], dismiss.CommandParameter);
|
||||
Assert.Equal("Close", dismiss.Content);
|
||||
Assert.True(dismiss.IsVisible);
|
||||
Assert.True(dismiss.IsEnabled);
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
||||
s => s.Extent.Height > s.Viewport.Height);
|
||||
|
||||
|
|
@ -145,6 +203,10 @@ public class HomePageNotificationsTests
|
|||
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
||||
await nav.PopAsync();
|
||||
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
|
||||
window.UpdateLayout();
|
||||
Assert.Empty(vm.Notifications);
|
||||
Assert.Null(transport.LastMethod);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -173,10 +235,29 @@ public class HomePageNotificationsTests
|
|||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
if (method == HttpMethod.Post)
|
||||
{
|
||||
LastPath = path;
|
||||
WasAnonymous = false;
|
||||
return DismissAsync<T>(path);
|
||||
}
|
||||
Assert.Equal(HttpMethod.Get, method);
|
||||
return ReadAsync<T>(path, false);
|
||||
}
|
||||
|
||||
private async Task<T> DismissAsync<T>(string path)
|
||||
{
|
||||
await DismissResponse;
|
||||
return (T)(object)new DismissedNotification
|
||||
{
|
||||
NotificationId = long.Parse(path[(path.LastIndexOf('/') + 1)..])
|
||||
};
|
||||
}
|
||||
|
||||
public HttpMethod? LastMethod { get; private set; }
|
||||
public Task DismissResponse { get; set; } = Task.CompletedTask;
|
||||
|
||||
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
||||
{
|
||||
LastPath = path;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public class HomePageProviderFlowTests
|
|||
var vm = new HomePageViewModel();
|
||||
|
||||
Assert.NotNull(vm.OpenAdministration);
|
||||
Assert.True(vm.OpenAdministration.CanExecute(null));
|
||||
Assert.False(vm.OpenAdministration.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal file
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System.Net.Http;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Billing;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class InvoicesApiClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Client_maps_invoice_operations_and_forwards_cancellation()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await client.GenerateAsync(7, ct);
|
||||
Assert.Equal(HttpMethod.Post, api.Method);
|
||||
Assert.Equal("https://business.example/api/v1/invoices/from-estimate/7", api.Path);
|
||||
Assert.Null(api.Body);
|
||||
Assert.Equal(ct, api.Cancellation);
|
||||
|
||||
await client.GetAsync(42, ct);
|
||||
Assert.Equal(HttpMethod.Get, api.Method);
|
||||
Assert.Equal("https://business.example/api/v1/invoices/42", api.Path);
|
||||
|
||||
var receipt = new RecordInvoicePaymentRequest
|
||||
{
|
||||
Reference = "bank-1", Amount = 30m, Currency = "EUR",
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
await client.RecordOfflineAsync(42, receipt, ct);
|
||||
Assert.Equal(HttpMethod.Post, api.Method);
|
||||
Assert.Equal("https://business.example/api/v1/invoices/42/payments/offline", api.Path);
|
||||
Assert.Same(receipt, api.Body);
|
||||
|
||||
await client.VerifyPayPalAsync(42, "TX-1", ct);
|
||||
Assert.Equal(HttpMethod.Post, api.Method);
|
||||
Assert.Equal("https://business.example/api/v1/invoices/42/payments/paypal", api.Path);
|
||||
Assert.Equal("TX-1", Assert.IsType<VerifyInvoicePayPalRequest>(api.Body).TransactionId);
|
||||
Assert.Equal(ct, api.Cancellation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_arguments_fail_before_transport()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.GenerateAsync(0, ct));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.GetAsync(-1, ct));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.RecordOfflineAsync(42, null!, ct));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => client.VerifyPayPalAsync(42, "", ct));
|
||||
Assert.Null(api.Path);
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public HttpMethod? Method { get; private set; }
|
||||
public string? Path { get; private set; }
|
||||
public object? Body { get; private set; }
|
||||
public CancellationToken Cancellation { get; private set; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Method = method;
|
||||
Path = path;
|
||||
Body = body;
|
||||
Cancellation = ct;
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ public class MainPageSaveTests
|
|||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
viewModel.NewPostCommand.Execute(null);
|
||||
|
||||
var page = new BlogsPage { DataContext = viewModel };
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests
|
|||
[InlineData(true)]
|
||||
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||
{
|
||||
var page = new BlogsPage();
|
||||
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
|
||||
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||
editor.MarkdownText = LongArticle;
|
||||
editor.IsPreviewVisible = preview;
|
||||
|
|
|
|||
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal file
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using System.Net;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PodcastBroadcasterTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-podcast-{Guid.NewGuid():N}.json");
|
||||
var devices = Platform.GetPodcastCaptureDevices;
|
||||
var permissions = Platform.RequestPodcastPermissionsAsync;
|
||||
var factory = Platform.CreatePodcastCaptureAsync;
|
||||
var store = new TokenStore(tokenPath);
|
||||
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
await using var api = new YavscApiClient(new Settings(), store);
|
||||
await using var transport = new Transport();
|
||||
try
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = () => new(
|
||||
[new("Front", "front"), new("Rear", "rear")],
|
||||
[new("None", ""), new("Android microphone", "microphone")]);
|
||||
Platform.RequestPodcastPermissionsAsync = (_, _) =>
|
||||
Task.FromException(new UnauthorizedAccessException("Camera denied"));
|
||||
Platform.CreatePodcastCaptureAsync = (_, _, _, _) =>
|
||||
throw new InvalidOperationException("Capture must not start after permission denial.");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
session.Refresh();
|
||||
var vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
|
||||
{
|
||||
Title = "Android live"
|
||||
};
|
||||
Assert.True(vm.CanBroadcast);
|
||||
vm.DetectDevicesCommand.Execute(null);
|
||||
Assert.Equal(2, vm.Cameras.Count);
|
||||
Assert.Equal("front", vm.SelectedCamera?.Value);
|
||||
Assert.Equal("microphone", vm.SelectedMicrophone?.Value);
|
||||
Assert.True(vm.StartCommand.CanExecute(null));
|
||||
await vm.StartCommand.ExecuteAsync(null);
|
||||
Assert.Contains("Camera denied", vm.Status);
|
||||
Assert.Equal(0, transport.CreateCount);
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.False(vm.IsBroadcasting);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = devices;
|
||||
Platform.RequestPodcastPermissionsAsync = permissions;
|
||||
Platform.CreatePodcastCaptureAsync = factory;
|
||||
File.Delete(tokenPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
|
||||
{
|
||||
await using var transport = new Transport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
FakeCapture? capture = null;
|
||||
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
||||
(directory, _) =>
|
||||
{
|
||||
capture = new FakeCapture(directory);
|
||||
return Task.FromResult<IPodcastCapture>(capture);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
await transport.FirstUpload.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
await capture!.StopAsync();
|
||||
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(new[] { 0, 1 }, transport.Uploads);
|
||||
Assert.Equal(new[] { "upload:0", "upload:1", "complete" }, transport.Events);
|
||||
Assert.True(capture.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_failure_stops_capture_and_preserves_unacknowledged_segments()
|
||||
{
|
||||
await using var transport = new Transport { FailUpload = true };
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
FakeCapture? capture = null;
|
||||
var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
||||
(directory, _) =>
|
||||
{
|
||||
capture = new FakeCapture(directory);
|
||||
return Task.FromResult<IPodcastCapture>(capture);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.Completion);
|
||||
Assert.True(capture!.Stopped);
|
||||
Assert.True(File.Exists(Path.Combine(capture.Directory, "segment-00000000.ts")));
|
||||
Assert.DoesNotContain("complete", transport.Events);
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.DisposeAsync().AsTask());
|
||||
Assert.True(capture.Disposed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (capture is not null) Directory.Delete(capture.Directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_failure_finalizes_empty_server_broadcast()
|
||||
{
|
||||
await using var transport = new Transport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => PodcastBroadcaster.StartAsync(client, "Denied",
|
||||
(_, _) => Task.FromException<IPodcastCapture>(new UnauthorizedAccessException("Camera denied")),
|
||||
TestContext.Current.CancellationToken));
|
||||
Assert.Equal(new[] { "complete" }, transport.Events);
|
||||
}
|
||||
|
||||
private sealed class FakeCapture : IPodcastCapture
|
||||
{
|
||||
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public string Directory { get; }
|
||||
public bool Stopped { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public Task Completion => _completion.Task;
|
||||
public FakeCapture(string directory)
|
||||
{
|
||||
Directory = directory;
|
||||
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
|
||||
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
|
||||
}
|
||||
public Task StopAsync()
|
||||
{
|
||||
if (Stopped) return Task.CompletedTask;
|
||||
File.WriteAllBytes(Path.Combine(Directory, "segment-00000001.ts"), [0x47, 2]);
|
||||
File.AppendAllText(Path.Combine(Directory, "index.m3u8"), "#EXTINF:0.5,\nsegment-00000001.ts\n");
|
||||
Stopped = true;
|
||||
_completion.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class Transport : IYavscApiClient
|
||||
{
|
||||
private readonly PodcastResponse _podcast = new(Guid.NewGuid(), "Test", "alice", "Alice",
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<int> Uploads { get; } = [];
|
||||
public List<string> Events { get; } = [];
|
||||
public bool FailUpload { get; init; }
|
||||
public int CreateCount { get; private set; }
|
||||
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
|
||||
else CreateCount++;
|
||||
return Task.FromResult((T)(object)_podcast);
|
||||
}
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
if (FailUpload) throw new HttpRequestException("Denied", null, HttpStatusCode.Forbidden);
|
||||
var sequence = int.Parse(new Uri(path).Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
|
||||
Uploads.Add(sequence);
|
||||
Events.Add($"upload:{sequence}");
|
||||
FirstUpload.TrySetResult();
|
||||
return Task.FromResult((T)(object)_podcast);
|
||||
}
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, body, ct);
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal file
166
src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PodcastNativeCaptureTests
|
||||
{
|
||||
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
|
||||
|
||||
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
|
||||
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
Core.Initialize();
|
||||
using var vlc = new LibVLC();
|
||||
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
|
||||
using var player = new MediaPlayer(vlc);
|
||||
PodcastBroadcaster.ConfigureOutput(media, root);
|
||||
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
player.EndReached += (_, _) => ended.TrySetResult();
|
||||
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed."));
|
||||
Assert.True(player.Play(media));
|
||||
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
|
||||
player.Stop();
|
||||
var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(playlist);
|
||||
Assert.True(segments.Count >= 2, playlist);
|
||||
Assert.Equal(0, segments[0].Sequence);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(root, segment.FileName), TestContext.Current.CancellationToken);
|
||||
Assert.True(bytes.Length > 188);
|
||||
Assert.Equal(0, bytes.Length % 188);
|
||||
Assert.InRange(segment.DurationSeconds, 0.001, 15);
|
||||
for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
|
||||
public async Task Broadcaster_uploads_final_segment_retries_safely_and_replay_decodes_in_LibVLC()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var transport = new CaptureTransport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/api/v1/");
|
||||
await using (var broadcaster = await PodcastBroadcaster.StartAsync(client, "Native test",
|
||||
vlc => new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!))), ct))
|
||||
{
|
||||
await transport.FirstTwo.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
|
||||
var livePlayback = PlayThroughAsync(client, transport.Podcast.Id, live: true, ct);
|
||||
await transport.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
|
||||
await broadcaster.StopAsync().WaitAsync(TimeSpan.FromSeconds(45), ct);
|
||||
Assert.True(broadcaster.Completion.IsCompletedSuccessfully);
|
||||
await livePlayback;
|
||||
}
|
||||
Assert.Equal(PodcastState.Completed, transport.Podcast.State);
|
||||
Assert.True(transport.Uploaded.Count >= 3);
|
||||
Assert.Equal(2, transport.FirstSegmentAttempts);
|
||||
await PlayThroughAsync(client, transport.Podcast.Id, live: false, ct);
|
||||
}
|
||||
|
||||
private static async Task PlayThroughAsync(PodcastApiClient client, Guid id, bool live, CancellationToken ct)
|
||||
{
|
||||
using var vlcPlayer = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
|
||||
using var input = new PodcastMediaInput(client, id, live);
|
||||
using var replay = new Media(vlcPlayer, input);
|
||||
replay.AddOption(":demux=ts");
|
||||
using var player = new MediaPlayer(vlcPlayer);
|
||||
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
input.Failed += message => ended.TrySetException(new InvalidOperationException(message));
|
||||
player.EndReached += (_, _) => ended.TrySetResult();
|
||||
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("Replay decode failed."));
|
||||
Assert.True(player.Play(replay));
|
||||
try
|
||||
{
|
||||
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), ct);
|
||||
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Video && track.Codec == 0x34363268);
|
||||
Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Audio && track.Codec == 0x6134706d);
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Cancel();
|
||||
player.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureTransport : IYavscApiClient
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
public HttpClient Http { get; } = new();
|
||||
public Dictionary<int, byte[]> Uploaded { get; } = new();
|
||||
private readonly List<PodcastSegmentResponse> _segments = new();
|
||||
public TaskCompletionSource FirstTwo { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public int FirstSegmentAttempts { get; private set; }
|
||||
public PodcastResponse Podcast { get; private set; } = new(Guid.NewGuid(), "Test", "alice", "Alice",
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
object result;
|
||||
if (typeof(T) == typeof(PodcastManifestResponse))
|
||||
result = new PodcastManifestResponse(Podcast, _segments.ToArray());
|
||||
else
|
||||
{
|
||||
if (path.EndsWith("/complete", StringComparison.Ordinal))
|
||||
Podcast = Podcast with { State = PodcastState.Completed };
|
||||
result = Podcast;
|
||||
}
|
||||
return Task.FromResult((T)result);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
var uri = new Uri(path);
|
||||
var sequence = int.Parse(uri.Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
|
||||
var duration = double.Parse(uri.Query["?duration=".Length..], System.Globalization.CultureInfo.InvariantCulture);
|
||||
using var content = contentFactory();
|
||||
var bytes = await content.ReadAsByteArrayAsync(ct);
|
||||
if (sequence >= 2) await ReadStarted.Task.WaitAsync(ct);
|
||||
lock (_sync)
|
||||
{
|
||||
if (Uploaded.TryGetValue(sequence, out var existing))
|
||||
Assert.Equal(existing, bytes);
|
||||
else
|
||||
{
|
||||
Assert.Equal(_segments.Count, sequence);
|
||||
Uploaded.Add(sequence, bytes);
|
||||
_segments.Add(new(sequence, duration, bytes.Length));
|
||||
Podcast = Podcast with { SegmentCount = _segments.Count, Bytes = Podcast.Bytes + bytes.Length };
|
||||
}
|
||||
if (sequence == 0 && ++FirstSegmentAttempts == 1)
|
||||
throw new HttpRequestException("Acknowledgement lost", null, HttpStatusCode.ServiceUnavailable);
|
||||
if (Uploaded.Count >= 2) FirstTwo.TrySetResult();
|
||||
return (T)(object)Podcast;
|
||||
}
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, body, ct);
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
var sequence = int.Parse(Path.GetFileNameWithoutExtension(new Uri(path).AbsolutePath),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
lock (_sync)
|
||||
{
|
||||
ReadStarted.TrySetResult();
|
||||
return Task.FromResult(Uploaded[sequence]);
|
||||
}
|
||||
}
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal file
163
src/PostIt/PostIt.Tests/PodcastTests.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PodcastTests
|
||||
{
|
||||
private static readonly Guid Id = Guid.Parse("9e2f6d47-2b9a-47ae-a870-073980bf2628");
|
||||
|
||||
[Fact]
|
||||
public void Parses_only_complete_ordered_capture_segments_with_invariant_durations()
|
||||
{
|
||||
var segments = HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2.5,\nsegment-00000000.ts\n#EXTINF:1.75,\nsegment-00000001.ts\n");
|
||||
Assert.Equal(2, segments.Count);
|
||||
Assert.Equal(2.5, segments[0].DurationSeconds);
|
||||
Assert.Equal(1, segments[1].Sequence);
|
||||
Assert.Empty(HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2,\n"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#EXTINF:NaN,\nsegment-00000000.ts\n")]
|
||||
[InlineData("#EXTINF:20,\nsegment-00000000.ts\n")]
|
||||
[InlineData("#EXTINF:2,\n../escape.ts\n")]
|
||||
[InlineData("#EXTINF:2,\nhttps://other.test/segment-00000000.ts\n")]
|
||||
[InlineData("#EXTINF:2,\nsegment-00000000.ts\n#EXTINF:2,\nsegment-00000002.ts\n")]
|
||||
public void Rejects_invalid_or_nonlocal_capture_playlist(string text)
|
||||
=> Assert.Throws<FormatException>(() => HlsCapturePlaylist.Parse(text));
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_uses_raw_transport_stream_and_replayable_content_factory()
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
|
||||
await client.UploadAsync(Id, 0, 2.5, new byte[] { 0x47, 1 }, TestContext.Current.CancellationToken);
|
||||
Assert.EndsWith($"/api/v1/podcasts/{Id}/segments/0?duration=2.5", transport.Path);
|
||||
Assert.Equal(HttpMethod.Put, transport.Method);
|
||||
Assert.NotNull(transport.ContentFactory);
|
||||
using var first = transport.ContentFactory();
|
||||
using var retry = transport.ContentFactory();
|
||||
Assert.Equal("video/mp2t", first.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(new byte[] { 0x47, 1 }, await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken),
|
||||
await retry.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
finally { CultureInfo.CurrentCulture = previous; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.example/")]
|
||||
[InlineData("https://api.example/api/v1/")]
|
||||
[InlineData("https://api.example/api/v1")]
|
||||
public async Task Configured_api_url_never_duplicates_the_api_prefix(string baseUrl)
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
var client = new PodcastApiClient(transport, () => baseUrl);
|
||||
await client.GetAsync(Id, TestContext.Current.CancellationToken);
|
||||
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}", transport.Path);
|
||||
}
|
||||
[Fact]
|
||||
public void Authenticated_playback_reads_segments_in_order_and_reports_end_of_replay()
|
||||
{
|
||||
var transport = new RecordingTransport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
using var input = new PodcastMediaInput(client, Id, live: false);
|
||||
var buffer = Marshal.AllocHGlobal(4);
|
||||
try
|
||||
{
|
||||
Assert.True(input.Open(out var size));
|
||||
Assert.Equal(ulong.MaxValue, size);
|
||||
Assert.Equal(2, input.Read(buffer, 4));
|
||||
Assert.Equal(0x47, Marshal.ReadByte(buffer));
|
||||
Assert.Equal(0, input.Read(buffer, 4));
|
||||
Assert.EndsWith($"/{Id}/segments/0.ts", transport.DownloadPath);
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Playback_errors_are_reported_without_crossing_the_native_callback_boundary()
|
||||
{
|
||||
var transport = new RecordingTransport { FailDownload = true };
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
using var input = new PodcastMediaInput(client, Id, live: false);
|
||||
string? error = null;
|
||||
input.Failed += message => error = message;
|
||||
var buffer = Marshal.AllocHGlobal(4);
|
||||
try
|
||||
{
|
||||
Assert.Equal(-1, input.Read(buffer, 4));
|
||||
Assert.Contains("401", error);
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_playback_joins_three_segments_behind_the_edge_and_cancellation_is_quiet()
|
||||
{
|
||||
var transport = new RecordingTransport { SegmentCount = 8 };
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
using var input = new PodcastMediaInput(client, Id, live: true);
|
||||
string? error = null;
|
||||
input.Failed += message => error = message;
|
||||
var buffer = Marshal.AllocHGlobal(4);
|
||||
try
|
||||
{
|
||||
Assert.Equal(2, input.Read(buffer, 4));
|
||||
Assert.EndsWith($"/{Id}/segments/5.ts", transport.DownloadPath);
|
||||
input.Cancel();
|
||||
Assert.Equal(-1, input.Read(buffer, 4));
|
||||
Assert.Null(error);
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
|
||||
private sealed class RecordingTransport : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public HttpMethod? Method { get; private set; }
|
||||
public string? Path { get; private set; }
|
||||
public string? DownloadPath { get; private set; }
|
||||
public Func<HttpContent>? ContentFactory { get; private set; }
|
||||
public bool FailDownload { get; init; }
|
||||
public int SegmentCount { get; init; } = 1;
|
||||
|
||||
private static PodcastResponse Podcast => new(Id, "Live", "alice", "Alice",
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Completed, 1, 2, 2);
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Method = method;
|
||||
Path = path;
|
||||
object response = typeof(T) == typeof(PodcastManifestResponse)
|
||||
? new PodcastManifestResponse(Podcast with { SegmentCount = SegmentCount },
|
||||
Enumerable.Range(0, SegmentCount).Select(i => new PodcastSegmentResponse(i, 2, 2)).ToArray()) : Podcast;
|
||||
return Task.FromResult((T)response);
|
||||
}
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
ContentFactory = contentFactory;
|
||||
return CallAsync<T>(method, path, ct: ct);
|
||||
}
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, body, ct);
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
DownloadPath = path;
|
||||
if (FailDownload) throw new HttpRequestException("401 Unauthorized");
|
||||
return Task.FromResult(new byte[] { 0x47, 1 });
|
||||
}
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,10 @@
|
|||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>PostIt.Tests</RootNamespace>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
|
|
@ -25,5 +25,8 @@
|
|||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup></ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\PostIt.Android\Services\CameraFrameOrientation.cs" Link="Android\CameraFrameOrientation.cs" />
|
||||
<Compile Include="..\PostIt.Android\Services\CameraFrameClock.cs" Link="Android\CameraFrameClock.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -58,6 +58,7 @@ public class PostItViewModelTests
|
|||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
||||
Assert.Same(selected, vm.SelectedPost);
|
||||
vm.IsEditing = true;
|
||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
||||
vm.DraftTitle = "Titre modifié";
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
|
@ -67,11 +68,13 @@ public class PostItViewModelTests
|
|||
await vm.SaveAsync();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Same(page, navigation.NavigationStack.Last());
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Same(selected, vm.SelectedPost);
|
||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
||||
Assert.Equal("Titre modifié", vm.DraftTitle);
|
||||
Assert.Equal("Corps", vm.DraftArticle);
|
||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
||||
Assert.Equal(Avalonia.Media.FontWeight.Normal, page.SaveButton.FontWeight);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal file
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class TransportStreamCaptureTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Segments_on_IDR_and_flushes_final_segment_with_real_PTS_durations()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
for (var i = 0; i <= 120; i++) input.Write(Video(i * 3000L, i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(2, segments[0].DurationSeconds);
|
||||
Assert.Equal(2, segments[1].DurationSeconds);
|
||||
Assert.InRange(segments[2].DurationSeconds, 0.033, 0.034);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(0, bytes.Length % 188);
|
||||
Assert.Equal(Pat(), bytes[..188]);
|
||||
Assert.Equal(Pmt(), bytes[188..376]);
|
||||
Assert.Contains((byte)0x65, bytes[376..]);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Rejects_corrupt_or_truncated_transport_without_publishing_a_segment(bool truncated)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream(new byte[truncated ? 187 : 188]);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken));
|
||||
Assert.False(File.Exists(Path.Combine(directory, "index.m3u8")));
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preserves_out_of_band_H264_configuration_at_every_segment_start()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
var configuration = Video(0, false);
|
||||
configuration[22] = 0x67;
|
||||
new byte[] { 0, 0, 0, 1, 0x68, 0x80 }.CopyTo(configuration, 25);
|
||||
input.Write(configuration);
|
||||
for (var i = 0; i <= 120; i++) input.Write(Video(90000 + i * 3000L, i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(0x67, bytes[376 + 22]);
|
||||
Assert.Equal(0x68, bytes[376 + 29]);
|
||||
Assert.Equal(0x65, bytes[564 + 22]);
|
||||
Assert.Equal((bytes[564 + 3] - 1) & 0x0f, bytes[376 + 3] & 0x0f);
|
||||
Assert.Equal(bytes[(564 + 13)..(564 + 18)], bytes[(376 + 13)..(376 + 18)]);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handles_33_bit_PTS_wrap_without_negative_duration()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
for (var i = 0; i <= 120; i++)
|
||||
input.Write(Video(((1L << 33) - 90000 + i * 3000L) & ((1L << 33) - 1), i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(2, segments[0].DurationSeconds);
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
private static byte[] Packet(int pid)
|
||||
{
|
||||
var packet = Enumerable.Repeat((byte)0xff, 188).ToArray();
|
||||
packet[0] = 0x47;
|
||||
packet[1] = (byte)(0x40 | (pid >> 8));
|
||||
packet[2] = (byte)pid;
|
||||
packet[3] = 0x10;
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Pat()
|
||||
{
|
||||
var packet = Packet(0);
|
||||
packet[4] = 0;
|
||||
new byte[] { 0, 0xb0, 13, 0, 1, 0xc1, 0, 0, 0, 1, 0xe1, 0 }.CopyTo(packet, 5);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Pmt()
|
||||
{
|
||||
var packet = Packet(256);
|
||||
packet[4] = 0;
|
||||
new byte[] { 2, 0xb0, 18, 0, 1, 0xc1, 0, 0, 0xe1, 1, 0xf0, 0,
|
||||
0x1b, 0xe1, 1, 0xf0, 0 }.CopyTo(packet, 5);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Video(long pts, bool idr)
|
||||
{
|
||||
var packet = Packet(257);
|
||||
new byte[] { 0, 0, 1, 0xe0, 0, 0, 0x84, 0x80, 5 }.CopyTo(packet, 4);
|
||||
packet[13] = (byte)(0x21 | ((pts >> 29) & 0xe));
|
||||
packet[14] = (byte)(pts >> 22);
|
||||
packet[15] = (byte)(1 | ((pts >> 14) & 0xfe));
|
||||
packet[16] = (byte)(pts >> 7);
|
||||
packet[17] = (byte)(1 | ((pts << 1) & 0xfe));
|
||||
new byte[] { 0, 0, 0, 1, idr ? (byte)0x65 : (byte)0x41, 0x88 }.CopyTo(packet, 18);
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
|
@ -24,7 +23,8 @@ namespace PostIt;
|
|||
public partial class App : Application, ILiveView, IVMPusherApp
|
||||
{
|
||||
public static bool LiveMode { get; private set; }
|
||||
private int _bootStarted;
|
||||
private Task? _bootTask;
|
||||
private bool _sessionNavigationConfigured;
|
||||
public static string? CliConfigFileSpecification { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -125,6 +125,14 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
|
||||
public async Task<Page> PushPageAsync(ViewModelBase viewModel)
|
||||
{
|
||||
var mainView = View ?? throw new InvalidOperationException("The navigation root is not initialized.");
|
||||
if (OperatingSystem.IsAndroid() &&
|
||||
mainView.NavRoot.NavigationStack.LastOrDefault() is { } currentPage &&
|
||||
ReferenceEquals(currentPage.DataContext, viewModel))
|
||||
{
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
|
||||
if (template is null)
|
||||
{
|
||||
|
|
@ -148,8 +156,6 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
|
||||
page.DataContext = viewModel;
|
||||
|
||||
MainView? mainView = (Current as App).View;
|
||||
|
||||
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
|
||||
var stack = mainView.NavRoot.NavigationStack;
|
||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
|
||||
|
|
@ -171,12 +177,25 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
singleViewFactoryApplicationLifetime.MainViewFactory =
|
||||
() =>
|
||||
{
|
||||
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
|
||||
ApplyStettings(settings);
|
||||
return View!;
|
||||
};
|
||||
() => CreateActivityView(settings);
|
||||
}
|
||||
|
||||
internal MainView CreateActivityView(Settings settings)
|
||||
{
|
||||
var pages = View?.NavRoot.NavigationStack
|
||||
.Select(page => (PageType: page.GetType(), page.DataContext))
|
||||
.ToArray();
|
||||
var rootView = ServiceProvider!.GetRequiredService<MainView>();
|
||||
var restoredPages = pages?.Select(state =>
|
||||
{
|
||||
// Do not reuse controls belonging to a destroyed Android activity.
|
||||
var page = (Page)ActivatorUtilities.CreateInstance(ServiceProvider, state.PageType);
|
||||
page.DataContext = state.DataContext;
|
||||
return page;
|
||||
}).ToArray();
|
||||
ConfigureRootView(rootView, restoredPages);
|
||||
ApplyStettings(settings);
|
||||
return rootView;
|
||||
}
|
||||
|
||||
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
|
|
@ -196,16 +215,42 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
}
|
||||
|
||||
public void ConfigureRootView(MainView rootView)
|
||||
=> ConfigureRootView(rootView, null);
|
||||
|
||||
private void ConfigureRootView(MainView rootView, Page[]? restoredPages)
|
||||
{
|
||||
var initialized = false;
|
||||
rootView.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
if (initialized)
|
||||
return;
|
||||
initialized = true;
|
||||
if (restoredPages is { Length: > 0 })
|
||||
{
|
||||
foreach (var page in restoredPages)
|
||||
await rootView.NavRoot.PushAsync(page, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
await BootOnceAsync();
|
||||
if (rootView.NavRoot.NavigationStack.Count == 0)
|
||||
{
|
||||
var home = new HomePage
|
||||
{
|
||||
DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>()
|
||||
};
|
||||
await rootView.NavRoot.PushAsync(home, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.LogoutCompleted += () =>
|
||||
if (!_sessionNavigationConfigured)
|
||||
{
|
||||
// Remplacer Window.NavRoot par rootView.NavRoot
|
||||
rootView.NavRoot.PopToRootAsync();
|
||||
};
|
||||
sessionStatus.LogoutCompleted += () => View!.NavRoot.PopToRootAsync();
|
||||
_sessionNavigationConfigured = true;
|
||||
}
|
||||
if (rootView.DataContext==null)
|
||||
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
|
||||
|
||||
|
|
@ -233,44 +278,29 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
|||
}
|
||||
|
||||
|
||||
private async Task BootOnceAsync()
|
||||
private Task BootOnceAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await BootAsync(this.ServiceProvider!, api);
|
||||
return _bootTask ??= BootAsync(ServiceProvider!, api);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run once after the main window is shown: try to refresh the
|
||||
/// cached OIDC tokens silently; on success, push MainPage on top
|
||||
/// of HomePage so the user lands on the blog editor already
|
||||
/// authenticated. On failure (refresh token rejected, no bundle
|
||||
/// on disk), leave them on HomePage and the Login button is the
|
||||
/// next step.
|
||||
/// Refresh cached OIDC tokens once per application process.
|
||||
/// Each root view initializes or restores its own navigation stack separately.
|
||||
/// </summary>
|
||||
private static async Task BootAsync(
|
||||
IServiceProvider provider,
|
||||
YavscApiClient api)
|
||||
{
|
||||
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
|
||||
await api.TrySilentLoginAsync().ConfigureAwait(true);
|
||||
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
|
||||
sessionStatus.Refresh();
|
||||
var homePageVm = provider.GetRequiredService<HomePageViewModel>();
|
||||
var app = (App)Current!;
|
||||
await app.PushPageAsync(homePageVm);
|
||||
await sessionStatus.RefreshAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
|
||||
/// mapped page (via <see cref="ViewLocator"/>) on top
|
||||
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
|
||||
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
|
||||
/// (interactive login from the banner). Pulled out as a helper so
|
||||
/// the two callers can't drift apart.
|
||||
/// of the current navigation stack.
|
||||
/// </summary>
|
||||
public static async Task PushBlogsPageAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,9 +34,12 @@ public static class ServiceCollectionHelpers
|
|||
() => settings.Authentication?.Authority);
|
||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||
var invoicesClient = new InvoicesApiClient(api, () => settings.ApiUrl);
|
||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
|
||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||
var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
|
|
@ -44,12 +47,23 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<MainWindow>();
|
||||
services.AddTransient<MainView>();
|
||||
services.AddTransient<BlogsPage>();
|
||||
services.AddTransient<VideoPage>();
|
||||
services.AddTransient<PodcastsPage>();
|
||||
|
||||
|
||||
// Models
|
||||
services.AddSingleton<ChatViewModel>();
|
||||
|
||||
// SettingsPage is a singleton: there must be one and only one
|
||||
// Android activities need fresh controls; retain state in singleton VMs.
|
||||
void AddReusablePage<TPage>() where TPage : class
|
||||
{
|
||||
if (OperatingSystem.IsAndroid())
|
||||
services.AddTransient<TPage>();
|
||||
else
|
||||
services.AddSingleton<TPage>();
|
||||
}
|
||||
|
||||
// On other platforms SettingsPage is a singleton: 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
|
||||
|
|
@ -60,12 +74,12 @@ public static class ServiceCollectionHelpers
|
|||
// 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.AddSingleton<AboutPage>();
|
||||
services.AddSingleton<HomePage>();
|
||||
services.AddSingleton<SignaturePage>();
|
||||
services.AddSingleton<CirclesPage>();
|
||||
services.AddSingleton<ActivitiesPage>();
|
||||
AddReusablePage<SettingsPage>();
|
||||
AddReusablePage<AboutPage>();
|
||||
AddReusablePage<HomePage>();
|
||||
AddReusablePage<SignaturePage>();
|
||||
AddReusablePage<CirclesPage>();
|
||||
AddReusablePage<ActivitiesPage>();
|
||||
services.AddTransient<CommandFormsPage>();
|
||||
services.AddTransient<ChatPage>();
|
||||
services.AddTransient<RdvPage>();
|
||||
|
|
@ -80,7 +94,6 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<UserProfilePage>();
|
||||
services.AddTransient<PerformerConfigurationPage>();
|
||||
services.AddTransient<AdministrationPage>();
|
||||
services.AddTransient<ResourceUsagePage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
|
|
@ -92,9 +105,13 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(activityClient);
|
||||
services.AddSingleton(billingClient);
|
||||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(invoicesClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(administrationClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(podcastClient);
|
||||
services.AddTransient<PodcastsPageViewModel>();
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@
|
|||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<InternalsVisibleTo Include="PostIt.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="postit-settings.json">
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="DirectShowLib" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" />
|
||||
<PackageReference Include="Live.Avalonia" />
|
||||
<PackageReference Include="Mapsui" />
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="MarkdownViewer.Core" />
|
||||
<PackageReference Include="Markdown.Avalonia.Tight" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Mono.Options" />
|
||||
|
|
@ -50,12 +52,11 @@
|
|||
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Lang/Resources.resx">
|
||||
<EmbeddedResource Update="Lang/Resources.resx">
|
||||
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Lang/Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
|
|
@ -64,4 +65,4 @@
|
|||
</Compile>
|
||||
<Watch Include="**\*.xaml" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
44
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
44
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Markdown.Avalonia.Utils;
|
||||
using Avalonia.Logging;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class BlogImageResolver : IPathResolver
|
||||
{
|
||||
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public BlogImageResolver() : this(Client) { }
|
||||
|
||||
public BlogImageResolver(HttpClient client) => _client = client;
|
||||
|
||||
public string? AssetPathRoot { get; set; }
|
||||
public IEnumerable<string>? CallerAssemblyNames { get; set; }
|
||||
|
||||
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
|
||||
{
|
||||
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
|
||||
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
|
||||
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
|
||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
|
||||
try
|
||||
{
|
||||
// The Markdown decoder needs a seekable stream positioned at the start.
|
||||
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
|
||||
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Abstract.Files;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class BlogResourceService
|
||||
{
|
||||
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public BlogResourceService() : this(SharedClient) { }
|
||||
|
||||
public BlogResourceService(HttpClient client) => _client = client;
|
||||
|
||||
public static Uri GetUserBaseUri(string authority, string login)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
|
||||
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
|
||||
}
|
||||
|
||||
public static Uri Resolve(string link, string authority, string login)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link))
|
||||
throw new ArgumentException("Le lien est vide.", nameof(link));
|
||||
var origin = GetAuthority(authority);
|
||||
var uri = link.StartsWith("/", StringComparison.Ordinal)
|
||||
? new Uri(origin, link)
|
||||
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
|
||||
? absolute
|
||||
: new Uri(GetUserBaseUri(authority, login), link);
|
||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
|
||||
return uri;
|
||||
}
|
||||
|
||||
public static bool IsUserResource(Uri uri, string authority)
|
||||
{
|
||||
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
|
||||
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
return false;
|
||||
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
|
||||
var separator = path.IndexOf('/');
|
||||
return separator > 0 && separator < path.Length - 1;
|
||||
}
|
||||
|
||||
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
|
||||
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
fallback.EnsureSuccessStatusCode();
|
||||
return GetMimeType(fallback, uri);
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
return GetMimeType(response, uri);
|
||||
}
|
||||
|
||||
private static string GetMimeType(HttpResponseMessage response, Uri uri)
|
||||
{
|
||||
var mime = response.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
|
||||
return mime;
|
||||
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
|
||||
{
|
||||
".mp4" or ".m4v" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".ogv" => "video/ogg",
|
||||
".ogg" => "audio/ogg",
|
||||
".mov" => "video/quicktime",
|
||||
".mkv" => "video/x-matroska",
|
||||
".avi" => "video/x-msvideo",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".pdf" => "application/pdf",
|
||||
_ => mime ?? "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri GetAuthority(string authority)
|
||||
{
|
||||
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||
|| !string.IsNullOrEmpty(uri.UserInfo))
|
||||
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
|
||||
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
|
||||
}
|
||||
}
|
||||
201
src/PostIt/PostIt/Services/CameraStreamer.cs
Normal file
201
src/PostIt/PostIt/Services/CameraStreamer.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using LibVLCSharp.Shared;
|
||||
|
||||
using DirectShowLib;
|
||||
|
||||
public class CameraStreamer
|
||||
{
|
||||
private readonly LibVLC _libVLC;
|
||||
|
||||
public static LibVLC CreateHeadlessLibVlc()
|
||||
=> new(
|
||||
"--no-xlib",
|
||||
"--avcodec-hw=none",
|
||||
"--vout=dummy",
|
||||
"--aout=dummy",
|
||||
"--intf=dummy");
|
||||
|
||||
public static LibVLC CreatePreferredLibVlc()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return new LibVLC();
|
||||
|
||||
if (ShouldDisableHardwareAcceleration())
|
||||
return CreateHeadlessLibVlc();
|
||||
|
||||
var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (preferHardware && IsVaapiUsable())
|
||||
return new LibVLC();
|
||||
|
||||
return CreateHeadlessLibVlc();
|
||||
}
|
||||
|
||||
private static bool ShouldDisableHardwareAcceleration()
|
||||
=> string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsVaapiUsable()
|
||||
{
|
||||
var vaapiDrivers = new[]
|
||||
{
|
||||
"/usr/lib/x86_64-linux-gnu/dri/nvidia_drv_video.so",
|
||||
"/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so",
|
||||
"/usr/lib/x86_64-linux-gnu/dri/i915_drv_video.so",
|
||||
"/usr/lib/x86_64-linux-gnu/dri/radeonsi_dri.so",
|
||||
"/usr/lib/x86_64-linux-gnu/dri/iris_dri.so",
|
||||
"/usr/lib64/dri/nvidia_drv_video.so",
|
||||
"/usr/lib64/dri/i965_drv_video.so",
|
||||
"/usr/lib64/dri/i915_drv_video.so",
|
||||
"/usr/lib64/dri/radeonsi_dri.so",
|
||||
"/usr/lib64/dri/iris_dri.so"
|
||||
};
|
||||
|
||||
if (!vaapiDrivers.Any(File.Exists))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var processInfo = new ProcessStartInfo("vainfo")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
using var process = Process.Start(processInfo);
|
||||
if (process is null)
|
||||
return false;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(2000);
|
||||
|
||||
var combined = (output + Environment.NewLine + error).Trim();
|
||||
return process.ExitCode == 0
|
||||
&& !string.IsNullOrWhiteSpace(combined)
|
||||
&& combined.Contains("VA-API version", StringComparison.OrdinalIgnoreCase)
|
||||
&& !combined.Contains("failed", StringComparison.OrdinalIgnoreCase)
|
||||
&& !combined.Contains("unknown libva error", StringComparison.OrdinalIgnoreCase)
|
||||
&& !combined.Contains("va_openDriver() returns -1", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public CameraStreamer(LibVLC libVLC)
|
||||
{
|
||||
_libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Récupère la liste des périphériques de capture disponibles sur la machine.
|
||||
/// Retourne un dictionnaire : Clé = Nom lisible, Valeur = Identifiant/Chemin système
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GetCaptureDevices()
|
||||
{
|
||||
var devices = new Dictionary<string, string>();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Détection Windows via DirectShow
|
||||
DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice);
|
||||
foreach (var device in capDevices)
|
||||
{
|
||||
using (device)
|
||||
devices.TryAdd(device.Name, device.Name);
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Détection Linux via V4L2 (Video4Linux2)
|
||||
if (Directory.Exists("/dev"))
|
||||
{
|
||||
var videoFiles = Directory.GetFiles("/dev", "video*");
|
||||
foreach (var path in videoFiles)
|
||||
{
|
||||
// Exemple : Clé = "Caméra (/dev/video0)", Valeur = "/dev/video0"
|
||||
devices.Add($"Caméra ({path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new PlatformNotSupportedException("OS non supporté pour la capture automatique dans cet exemple.");
|
||||
}
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
public Dictionary<string, string> GetAudioCaptureDevices()
|
||||
{
|
||||
var devices = new Dictionary<string, string>();
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
foreach (var device in DsDevice.GetDevicesOfCat(FilterCategory.AudioInputDevice))
|
||||
{
|
||||
using (device)
|
||||
devices.TryAdd(device.Name, device.Name);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
devices.Add("Microphone par defaut (PulseAudio)", "default");
|
||||
else
|
||||
throw new PlatformNotSupportedException("La capture est disponible sous Windows et Linux.");
|
||||
return devices;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crée un objet Media configuré pour le périphérique sélectionné.
|
||||
/// </summary>
|
||||
/// <param name="deviceValue">Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux)</param>
|
||||
public Media CreateCaptureMedia(string deviceValue, string? audioDevice = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceValue);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Configuration pour DirectShow (Windows)
|
||||
var media = new Media(_libVLC, "dshow://", FromType.FromLocation);
|
||||
media.AddOption($":dshow-vdev={deviceValue}");
|
||||
media.AddOption($":dshow-adev={audioDevice ?? "none"}");
|
||||
media.AddOption(":live-caching=200");
|
||||
media.AddOption(":thread-cout=0");
|
||||
media.AddOption(":avcodec-hw=none");
|
||||
media.AddOption(":vcodec=h264");
|
||||
|
||||
// Options optionnelles (Exemple : Forcer la résolution ou le framerate)
|
||||
// media.AddOption(":dshow-size=1280x720");
|
||||
// media.AddOption(":dshow-fps=30");
|
||||
|
||||
return media;
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Configuration pour V4L2 (Linux)
|
||||
var media = new Media(_libVLC, "v4l2://", FromType.FromLocation);
|
||||
media.AddOption($":v4l2-dev={deviceValue}");
|
||||
media.AddOption(":live-caching=200");
|
||||
media.AddOption(":thread-cout=0");
|
||||
media.AddOption(":avcodec-hw=none");
|
||||
media.AddOption(":vcodec=h264");
|
||||
if (!string.IsNullOrWhiteSpace(audioDevice))
|
||||
media.AddOption($":input-slave=pulse://{audioDevice}");
|
||||
|
||||
// Options optionnelles pour Linux
|
||||
// media.AddOption(":v4l2-width=1280");
|
||||
// media.AddOption(":v4l2-height=720");
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
throw new PlatformNotSupportedException();
|
||||
}
|
||||
}
|
||||
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal file
43
src/PostIt/PostIt/Services/HlsCapturePlaylist.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed record CaptureSegment(int Sequence, double DurationSeconds, string FileName);
|
||||
|
||||
public static partial class HlsCapturePlaylist
|
||||
{
|
||||
[GeneratedRegex(@"^segment-(\d{8})\.ts$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SegmentName();
|
||||
|
||||
public static IReadOnlyList<CaptureSegment> Parse(string playlist)
|
||||
{
|
||||
var segments = new List<CaptureSegment>();
|
||||
double? duration = null;
|
||||
foreach (var line in playlist.Split('\n'))
|
||||
{
|
||||
var text = line.Trim();
|
||||
if (text.StartsWith("#EXTINF:", StringComparison.Ordinal))
|
||||
{
|
||||
if (!double.TryParse(text[8..].Split(',')[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||
|| !double.IsFinite(value) || value <= 0 || value > 15)
|
||||
throw new FormatException("Duree du segment LibVLC invalide.");
|
||||
duration = value;
|
||||
}
|
||||
else if (text.Length > 0 && !text.StartsWith('#'))
|
||||
{
|
||||
var match = SegmentName().Match(text);
|
||||
if (!match.Success || duration is null)
|
||||
throw new FormatException("Index HLS LibVLC invalide.");
|
||||
var sequence = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
if (segments.Count > 0 && sequence != segments[^1].Sequence + 1)
|
||||
throw new FormatException("Segments HLS non consecutifs.");
|
||||
segments.Add(new(sequence, duration.Value, text));
|
||||
duration = null;
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
16
src/PostIt/PostIt/Services/IPodcastCapture.cs
Normal file
16
src/PostIt/PostIt/Services/IPodcastCapture.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed record CaptureDevice(string Name, string Value);
|
||||
public sealed record PodcastCaptureDevices(
|
||||
IReadOnlyList<CaptureDevice> Cameras, IReadOnlyList<CaptureDevice> Microphones);
|
||||
|
||||
/// <summary>A running encoder publishing closed segments and an atomic index.m3u8.</summary>
|
||||
public interface IPodcastCapture : IAsyncDisposable
|
||||
{
|
||||
Task Completion { get; }
|
||||
Task StopAsync();
|
||||
}
|
||||
62
src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
Normal file
62
src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using LibVLCSharp.Shared;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
internal sealed class LibVlcPodcastCapture : IPodcastCapture
|
||||
{
|
||||
private readonly LibVLC _vlc;
|
||||
private readonly Media _media;
|
||||
private readonly MediaPlayer _player;
|
||||
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private bool _stopped;
|
||||
|
||||
public Task Completion => _completion.Task;
|
||||
|
||||
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string directory)
|
||||
{
|
||||
Core.Initialize();
|
||||
_vlc = CameraStreamer.CreatePreferredLibVlc();
|
||||
try
|
||||
{
|
||||
_media = createMedia(_vlc);
|
||||
PodcastBroadcaster.ConfigureOutput(_media, directory);
|
||||
_player = new MediaPlayer(_vlc);
|
||||
_player.EncounteredError += OnError;
|
||||
if (!_player.Play(_media))
|
||||
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_player?.Dispose();
|
||||
_media?.Dispose();
|
||||
_vlc.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnError(object? sender, EventArgs e)
|
||||
=> _completion.TrySetException(new InvalidOperationException(
|
||||
"La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC."));
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_stopped) return;
|
||||
_stopped = true;
|
||||
await Task.Run(_player.Stop);
|
||||
_completion.TrySetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { await StopAsync(); }
|
||||
finally
|
||||
{
|
||||
_player.EncounteredError -= OnError;
|
||||
_player.Dispose();
|
||||
_media.Dispose();
|
||||
_vlc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,28 @@ using System;
|
|||
using IdentityModel.OidcClient.Browser;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using LibVLCSharp.Shared;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-platform access to native integration points used by the OIDC
|
||||
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
|
||||
/// not reference any UI framework; platform projects (PostIt.Android,
|
||||
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
|
||||
/// the shared OIDC login path can drive a native browser without taking
|
||||
/// a hard dependency on any specific UI toolkit.
|
||||
/// Native integration points populated by platform projects at startup.
|
||||
/// </summary>
|
||||
public static class Platform
|
||||
{
|
||||
public static Func<PodcastCaptureDevices>? GetPodcastCaptureDevices { get; set; }
|
||||
public static Func<string?, CancellationToken, Task>? RequestPodcastPermissionsAsync { get; set; }
|
||||
public static Func<string, string?, string, CancellationToken, Task<IPodcastCapture>>? CreatePodcastCaptureAsync { get; set; }
|
||||
|
||||
/// <summary>Platform-specific video surface inside the shared video page.</summary>
|
||||
public static Func<MediaPlayer, Control>? CreateVideoSurface { get; set; }
|
||||
|
||||
public static event Action? PlaybackSuspending;
|
||||
public static event Action? PlaybackResuming;
|
||||
public static void SuspendPlayback() => PlaybackSuspending?.Invoke();
|
||||
public static void ResumePlayback() => PlaybackResuming?.Invoke();
|
||||
|
||||
/// <summary>
|
||||
/// Default redirect URI for the running platform. The shared
|
||||
/// default uses a custom URI scheme (<c>postit://callback</c>)
|
||||
|
|
@ -48,4 +57,6 @@ public static class Platform
|
|||
/// </summary>
|
||||
public static Func<CancellationToken, Task<CurrentLocationResult>> TryGetCurrentLocationAsync { get; set; } =
|
||||
_ => Task.FromResult(CurrentLocationResult.Unavailable());
|
||||
|
||||
public static bool IsPodcastSupported { get; set; } = false;
|
||||
}
|
||||
|
|
|
|||
216
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal file
216
src/PostIt/PostIt/Services/PodcastBroadcaster.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
using LibVLCSharp.Shared;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class PodcastBroadcaster : IAsyncDisposable
|
||||
{
|
||||
private readonly PodcastApiClient _api;
|
||||
private readonly IPodcastCapture _capture;
|
||||
private readonly string _directory;
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly SemaphoreSlim _stopGate = new(1);
|
||||
private volatile bool _stopped;
|
||||
private bool _disposed;
|
||||
|
||||
public PodcastResponse Podcast { get; private set; }
|
||||
public Task Completion { get; private set; } = Task.CompletedTask;
|
||||
public event Action<PodcastResponse>? Progress;
|
||||
|
||||
private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast,
|
||||
IPodcastCapture capture, string directory)
|
||||
{
|
||||
_api = api;
|
||||
Podcast = podcast;
|
||||
_capture = capture;
|
||||
_directory = directory;
|
||||
}
|
||||
|
||||
public static void ConfigureOutput(Media media, string directory)
|
||||
{
|
||||
var path = directory.Replace('\\', '/').Replace("\"", "\\\"");
|
||||
media.AddOption(":sout=#transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30,"
|
||||
+ "acodec=mp4a,ab=128,channels=2,samplerate=48000}:std{"
|
||||
+ $"access=livehttp{{seglen=2,delsegs=false,numsegs=0,initial-segment-number=0,index=\"{path}/index.m3u8\","
|
||||
+ $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}");
|
||||
}
|
||||
|
||||
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
string camera, string? microphone, CancellationToken ct)
|
||||
=> await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct);
|
||||
|
||||
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
Func<LibVLC, Media> createMedia, CancellationToken ct)
|
||||
=> await StartAsync(api, title,
|
||||
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(createMedia, directory)), ct);
|
||||
|
||||
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
|
||||
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct)
|
||||
{
|
||||
IPodcastCapture? capture = null;
|
||||
PodcastBroadcaster? broadcaster = null;
|
||||
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PostIt", "podcast-capture", Guid.NewGuid().ToString("N"));
|
||||
PodcastResponse? podcast = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
podcast = await api.CreateAsync(title, ct);
|
||||
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
capture = await createCapture(directory, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
broadcaster = new PodcastBroadcaster(api, podcast, capture, directory);
|
||||
broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token);
|
||||
return broadcaster;
|
||||
}
|
||||
catch (Exception startupError)
|
||||
{
|
||||
if (broadcaster is not null)
|
||||
{
|
||||
broadcaster._cancellation.Dispose();
|
||||
broadcaster._stopGate.Dispose();
|
||||
}
|
||||
if (capture is not null) await capture.DisposeAsync();
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
if (podcast is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await api.CompleteAsync(podcast.Id, timeout.Token);
|
||||
}
|
||||
catch (Exception finishError)
|
||||
{
|
||||
throw new AggregateException("Capture impossible ; le direct serveur expirera automatiquement.", startupError, finishError);
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
await StopEncoderAsync();
|
||||
await Completion;
|
||||
}
|
||||
|
||||
private async Task StopEncoderAsync()
|
||||
{
|
||||
await _stopGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!_stopped)
|
||||
{
|
||||
await _capture.StopAsync();
|
||||
_stopped = true;
|
||||
}
|
||||
}
|
||||
finally { _stopGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task UploadAsync(CancellationToken ct)
|
||||
{
|
||||
var next = 0;
|
||||
var lastSegment = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var finalPass = _stopped || _capture.Completion.IsCompleted;
|
||||
if (_capture.Completion.IsCompleted)
|
||||
await _capture.Completion;
|
||||
var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length);
|
||||
if (pendingBytes > 64L * 1024 * 1024)
|
||||
throw new IOException("Le reseau est trop lent : la capture depasse 64 Mio de segments en attente.");
|
||||
var playlist = Path.Combine(_directory, "index.m3u8");
|
||||
if (File.Exists(playlist))
|
||||
{
|
||||
using var reader = new StreamReader(new FileStream(playlist, FileMode.Open,
|
||||
FileAccess.Read, FileShare.ReadWrite | FileShare.Delete));
|
||||
var segments = HlsCapturePlaylist.Parse(await reader.ReadToEndAsync(ct));
|
||||
foreach (var segment in segments.Where(s => s.Sequence >= next))
|
||||
{
|
||||
if (segment.Sequence != next)
|
||||
throw new InvalidDataException("Un segment manque dans la capture.");
|
||||
var path = Path.Combine(_directory, segment.FileName);
|
||||
if (new FileInfo(path).Length > 8 * 1024 * 1024)
|
||||
throw new IOException("Un segment de capture depasse 8 Mio.");
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct);
|
||||
var response = await UploadWithRetryAsync(segment, bytes, ct);
|
||||
Podcast = response;
|
||||
File.Delete(path);
|
||||
next++;
|
||||
lastSegment.Restart();
|
||||
Progress?.Invoke(response);
|
||||
}
|
||||
}
|
||||
if (finalPass)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
Podcast = await _api.CompleteAsync(Podcast.Id, timeout.Token);
|
||||
return;
|
||||
}
|
||||
if (lastSegment.Elapsed > TimeSpan.FromSeconds(25))
|
||||
throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la camera et les encodeurs H.264/AAC.");
|
||||
await Task.Delay(400, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Stop the camera even when uploading or parsing fails.
|
||||
await StopEncoderAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PodcastResponse> UploadWithRetryAsync(CaptureSegment segment, byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
return await _api.UploadAsync(Podcast.Id, segment.Sequence, segment.DurationSeconds, bytes, timeout.Token);
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < 2 && (ex.StatusCode is null
|
||||
or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)ex.StatusCode >= 500))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && attempt < 2)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cancellation.Cancel();
|
||||
try { await Completion; }
|
||||
finally
|
||||
{
|
||||
await _capture.DisposeAsync();
|
||||
_cancellation.Dispose();
|
||||
_stopGate.Dispose();
|
||||
// Keep failed, unacknowledged segments for manual recovery.
|
||||
if (Completion.IsCompletedSuccessfully && Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal file
78
src/PostIt/PostIt/Services/PodcastMediaInput.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using LibVLCSharp.Shared;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
// LibVLC consumes a sequential transport stream; credentials stay in the existing HTTP transport.
|
||||
public sealed class PodcastMediaInput(PodcastApiClient api, Guid id, bool live) : MediaInput
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private byte[] _segment = Array.Empty<byte>();
|
||||
private int _offset;
|
||||
private int _sequence = -1;
|
||||
public event Action<string>? Failed;
|
||||
|
||||
public override bool Open(out ulong size)
|
||||
{
|
||||
size = ulong.MaxValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override int Read(IntPtr buffer, uint length)
|
||||
{
|
||||
if (length == 0) return 0;
|
||||
try
|
||||
{
|
||||
var ct = _cancellation.Token;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
while (_offset == _segment.Length)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
var manifest = api.GetAsync(id, timeout.Token).GetAwaiter().GetResult();
|
||||
if (_sequence < 0)
|
||||
_sequence = live ? Math.Max(0, manifest.Segments.Count - 3) : 0;
|
||||
if (_sequence < manifest.Segments.Count)
|
||||
{
|
||||
_segment = api.DownloadSegmentAsync(id, _sequence, timeout.Token).GetAwaiter().GetResult();
|
||||
if (_segment.Length == 0 || _segment.Length != manifest.Segments[_sequence].Bytes)
|
||||
throw new InvalidOperationException("Segment podcast incomplet.");
|
||||
_sequence++;
|
||||
_offset = 0;
|
||||
break;
|
||||
}
|
||||
if (manifest.Podcast.State != PodcastState.Live) return 0;
|
||||
if (ct.WaitHandle.WaitOne(700)) ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
var count = (int)Math.Min(length, (uint)(_segment.Length - _offset));
|
||||
Marshal.Copy(_segment, _offset, buffer, count);
|
||||
_offset += count;
|
||||
return count;
|
||||
}
|
||||
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
|
||||
// No managed exception may escape a native LibVLC callback.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Failed?.Invoke($"Lecture du podcast interrompue : {ex.Message}");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool Seek(ulong offset) => false;
|
||||
public override void Close() { }
|
||||
public void Cancel() => _cancellation.Cancel();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
_cancellation.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
222
src/PostIt/PostIt/Services/TransportStreamCapture.cs
Normal file
222
src/PostIt/PostIt/Services/TransportStreamCapture.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
/// <summary>Segments a single-program H.264 MPEG-TS encoder output at IDR frames.</summary>
|
||||
public sealed class TransportStreamCapture(string directory)
|
||||
{
|
||||
private const int PacketSize = 188;
|
||||
private const int MaxSegmentBytes = 8 * 1024 * 1024;
|
||||
private readonly MemoryStream _frame = new();
|
||||
private readonly MemoryStream _video = new();
|
||||
private readonly StringBuilder _playlist = new("#EXTM3U\n");
|
||||
private byte[]? _pat;
|
||||
private byte[]? _pmt;
|
||||
private byte[]? _configuration;
|
||||
private int _pmtPid = -1;
|
||||
private int _videoPid = -1;
|
||||
private FileStream? _segment;
|
||||
private int _sequence;
|
||||
private long? _framePts;
|
||||
private long _startPts;
|
||||
private long _lastPts;
|
||||
private long _frameDuration = 3000;
|
||||
private long? _previousPts;
|
||||
private long _ptsEpoch;
|
||||
|
||||
public async Task RunAsync(Stream input, CancellationToken ct)
|
||||
{
|
||||
var packet = new byte[PacketSize];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < PacketSize)
|
||||
{
|
||||
var count = await input.ReadAsync(packet.AsMemory(read), ct);
|
||||
if (count == 0)
|
||||
{
|
||||
if (read != 0) throw new InvalidDataException("Paquet MPEG-TS tronque.");
|
||||
FlushFrame();
|
||||
CloseSegment(_lastPts + _frameDuration);
|
||||
return;
|
||||
}
|
||||
read += count;
|
||||
}
|
||||
Consume(packet);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_segment?.Dispose();
|
||||
_frame.Dispose();
|
||||
_video.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void Consume(byte[] packet)
|
||||
{
|
||||
if (packet[0] != 0x47 || (packet[1] & 0x80) != 0 || (packet[3] & 0xc0) != 0)
|
||||
throw new InvalidDataException("Flux MPEG-TS invalide.");
|
||||
var pid = ((packet[1] & 0x1f) << 8) | packet[2];
|
||||
var hasPayload = (packet[3] & 0x10) != 0;
|
||||
if (!hasPayload) return;
|
||||
var offset = 4;
|
||||
if ((packet[3] & 0x20) != 0) offset += 1 + packet[4];
|
||||
if (offset >= PacketSize) throw new InvalidDataException("Adaptation MPEG-TS invalide.");
|
||||
var start = (packet[1] & 0x40) != 0;
|
||||
if (pid == 0 || pid == _pmtPid)
|
||||
{
|
||||
if (!start) throw new InvalidDataException("Table MPEG-TS multi-paquets non prise en charge.");
|
||||
var section = offset + 1 + packet[offset];
|
||||
if (section + 3 > PacketSize) throw new InvalidDataException("Table MPEG-TS tronquee.");
|
||||
var end = section + 3 + (((packet[section + 1] & 0x0f) << 8) | packet[section + 2]);
|
||||
if (end > PacketSize) throw new InvalidDataException("Table MPEG-TS trop grande.");
|
||||
if (pid == 0)
|
||||
{
|
||||
if (packet[section] != 0 || end - section < 16)
|
||||
throw new InvalidDataException("PAT MPEG-TS invalide.");
|
||||
_pmtPid = ((packet[section + 10] & 0x1f) << 8) | packet[section + 11];
|
||||
_pat = (byte[])packet.Clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (packet[section] != 2 || end - section < 16)
|
||||
throw new InvalidDataException("PMT MPEG-TS invalide.");
|
||||
var entry = section + 12 + (((packet[section + 10] & 0x0f) << 8) | packet[section + 11]);
|
||||
while (entry + 5 <= end - 4)
|
||||
{
|
||||
if (packet[entry] == 0x1b)
|
||||
_videoPid = ((packet[entry + 1] & 0x1f) << 8) | packet[entry + 2];
|
||||
entry += 5 + (((packet[entry + 3] & 0x0f) << 8) | packet[entry + 4]);
|
||||
}
|
||||
_pmt = (byte[])packet.Clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pid == _videoPid && start)
|
||||
{
|
||||
FlushFrame();
|
||||
if (offset + 14 > PacketSize || packet[offset] != 0 || packet[offset + 1] != 0
|
||||
|| packet[offset + 2] != 1 || (packet[offset + 7] & 0x80) == 0)
|
||||
throw new InvalidDataException("Horodatage video MPEG-TS absent.");
|
||||
var ptsOffset = offset + 9;
|
||||
var pts = ((long)(packet[ptsOffset] & 0x0e) << 29)
|
||||
| ((long)packet[ptsOffset + 1] << 22) | ((long)(packet[ptsOffset + 2] & 0xfe) << 14)
|
||||
| ((long)packet[ptsOffset + 3] << 7) | (uint)(packet[ptsOffset + 4] >> 1);
|
||||
if (_previousPts is { } previous && pts < previous)
|
||||
{
|
||||
if (previous - pts < (1L << 32))
|
||||
throw new InvalidDataException("Horodatages video non croissants.");
|
||||
_ptsEpoch += 1L << 33;
|
||||
}
|
||||
_previousPts = pts;
|
||||
_framePts = pts + _ptsEpoch;
|
||||
offset += 9 + packet[offset + 8];
|
||||
if (offset > PacketSize) throw new InvalidDataException("En-tete PES invalide.");
|
||||
}
|
||||
if (_framePts is null) return;
|
||||
_frame.Write(packet);
|
||||
if (pid == _videoPid) _video.Write(packet, offset, PacketSize - offset);
|
||||
if (_frame.Length > MaxSegmentBytes)
|
||||
throw new IOException("Une image de capture depasse 8 Mio.");
|
||||
}
|
||||
|
||||
private void FlushFrame()
|
||||
{
|
||||
if (_framePts is not { } pts) return;
|
||||
var video = _video.GetBuffer().AsSpan(0, (int)_video.Length);
|
||||
var idr = false;
|
||||
var hasConfiguration = false;
|
||||
var hasSlice = false;
|
||||
for (var i = 0; i + 3 < video.Length; i++)
|
||||
if (video[i] == 0 && video[i + 1] == 0 && video[i + 2] == 1)
|
||||
{
|
||||
var type = video[i + 3] & 0x1f;
|
||||
if (type == 5) idr = true;
|
||||
if (type is 7 or 8) hasConfiguration = true;
|
||||
if (type is 1 or 5) hasSlice = true;
|
||||
}
|
||||
if (hasConfiguration && !hasSlice)
|
||||
{
|
||||
using var packets = new MemoryStream();
|
||||
for (var i = 0; i < _frame.Length; i += PacketSize)
|
||||
{
|
||||
var packet = _frame.GetBuffer().AsSpan(i, PacketSize);
|
||||
if ((((packet[1] & 0x1f) << 8) | packet[2]) == _videoPid)
|
||||
packets.Write(packet);
|
||||
}
|
||||
_configuration = packets.ToArray();
|
||||
}
|
||||
if (_segment is not null && idr && pts - _startPts >= 180000)
|
||||
CloseSegment(pts);
|
||||
if (_segment is null && idr)
|
||||
{
|
||||
if (_pat is null || _pmt is null)
|
||||
throw new InvalidDataException("Tables MPEG-TS manquantes.");
|
||||
_startPts = pts;
|
||||
_segment = File.Create(Path.Combine(directory, SegmentName()));
|
||||
_segment.Write(_pat);
|
||||
_segment.Write(_pmt);
|
||||
if (!hasConfiguration && _configuration is not null) WriteConfiguration(pts);
|
||||
}
|
||||
if (_segment is not null)
|
||||
{
|
||||
if (_segment.Length + _frame.Length > MaxSegmentBytes || pts - _startPts > 14 * 90000)
|
||||
throw new IOException("Segment trop grand ou absence d'image cle depuis 14 secondes.");
|
||||
_segment.Write(_frame.GetBuffer(), 0, (int)_frame.Length);
|
||||
if (pts > _lastPts && _lastPts >= _startPts)
|
||||
_frameDuration = Math.Min(pts - _lastPts, 9000);
|
||||
_lastPts = pts;
|
||||
}
|
||||
_frame.SetLength(0);
|
||||
_video.SetLength(0);
|
||||
_framePts = null;
|
||||
}
|
||||
|
||||
private string SegmentName() => $"segment-{_sequence:D8}.ts";
|
||||
|
||||
private void WriteConfiguration(long pts)
|
||||
{
|
||||
var packets = (byte[])_configuration!.Clone();
|
||||
var first = _frame.GetBuffer()[3] & 0x0f;
|
||||
var counter = (first - packets.Length / PacketSize) & 0x0f;
|
||||
for (var i = 0; i < packets.Length; i += PacketSize)
|
||||
{
|
||||
packets[i + 3] = (byte)((packets[i + 3] & 0xf0) | counter);
|
||||
counter = (counter + 1) & 0x0f;
|
||||
if ((packets[i + 1] & 0x40) == 0) continue;
|
||||
var offset = i + 4;
|
||||
if ((packets[i + 3] & 0x20) != 0) offset += packets[offset] + 1;
|
||||
offset += 9;
|
||||
if (offset + 5 > i + PacketSize) throw new InvalidDataException("Configuration H.264 invalide.");
|
||||
packets[offset] = (byte)(0x21 | ((pts >> 29) & 0x0e));
|
||||
packets[offset + 1] = (byte)(pts >> 22);
|
||||
packets[offset + 2] = (byte)(1 | ((pts >> 14) & 0xfe));
|
||||
packets[offset + 3] = (byte)(pts >> 7);
|
||||
packets[offset + 4] = (byte)(1 | ((pts << 1) & 0xfe));
|
||||
}
|
||||
_segment!.Write(packets);
|
||||
}
|
||||
|
||||
private void CloseSegment(long endPts)
|
||||
{
|
||||
if (_segment is null) return;
|
||||
_segment.Dispose();
|
||||
_segment = null;
|
||||
var duration = (endPts - _startPts) / 90000d;
|
||||
if (duration <= 0 || duration > 15) throw new InvalidDataException("Duree MPEG-TS invalide.");
|
||||
_playlist.Append("#EXTINF:").Append(duration.ToString("0.######", CultureInfo.InvariantCulture))
|
||||
.Append(",\n").Append(SegmentName()).Append('\n');
|
||||
var index = Path.Combine(directory, "index.m3u8");
|
||||
File.WriteAllText(index + ".tmp", _playlist.ToString());
|
||||
File.Move(index + ".tmp", index, overwrite: true);
|
||||
_sequence++;
|
||||
}
|
||||
}
|
||||
|
|
@ -540,8 +540,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
response = await sendOnce().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
using (response)
|
||||
{
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ public class ViewLocator : IDataTemplate
|
|||
return data switch
|
||||
{
|
||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
|
||||
PodcastsPageViewModel => services.GetRequiredService<PodcastsPage>(),
|
||||
Settings => services.GetRequiredService<SettingsPage>(),
|
||||
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||
|
|
@ -63,7 +65,6 @@ public class ViewLocator : IDataTemplate
|
|||
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
|
||||
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
|
||||
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
|
||||
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
|
||||
ChatViewModel => services.GetRequiredService<ChatPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
|
|
|
|||
69
src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
Normal file
69
src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class AdministrationFieldViewModel : ObservableObject
|
||||
{
|
||||
private readonly Type _type;
|
||||
private readonly bool _wasNull;
|
||||
public string Name { get; }
|
||||
public bool IsBoolean => _type == typeof(bool);
|
||||
public bool IsChoice => _type.IsEnum;
|
||||
public bool IsText => !IsBoolean && !IsChoice;
|
||||
public bool IsMultiline => Name is "Body" or "Description";
|
||||
public string[] Choices => IsChoice ? Enum.GetNames(_type) : [];
|
||||
|
||||
[ObservableProperty] public partial string Text { get; set; } = "";
|
||||
[ObservableProperty] public partial bool Checked { get; set; }
|
||||
|
||||
private AdministrationFieldViewModel(string name, Type type, object? value)
|
||||
{
|
||||
Name = name;
|
||||
_type = Nullable.GetUnderlyingType(type) ?? type;
|
||||
_wasNull = value is null;
|
||||
Checked = value is true;
|
||||
Text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AdministrationFieldViewModel> From<
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T record) where T : class
|
||||
=> typeof(T).GetProperties().Select(property =>
|
||||
new AdministrationFieldViewModel(property.Name, property.PropertyType, property.GetValue(record))).ToArray();
|
||||
|
||||
public static T Build<T>(IEnumerable<AdministrationFieldViewModel> fields) where T : class
|
||||
{
|
||||
var values = fields.ToDictionary(field => field.Name, field => field.ReadValue());
|
||||
var record = JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(values))
|
||||
?? throw new InvalidOperationException("Le formulaire est vide.");
|
||||
var errors = new List<ValidationResult>();
|
||||
if (!Validator.TryValidateObject(record, new ValidationContext(record), errors, true))
|
||||
throw new ValidationException(string.Join("; ", errors.Select(error => error.ErrorMessage)));
|
||||
return record;
|
||||
}
|
||||
|
||||
private object? ReadValue()
|
||||
{
|
||||
if (IsBoolean) return Checked;
|
||||
if (_type == typeof(string)) return _wasNull && Text.Length == 0 ? null : Text;
|
||||
if (IsChoice)
|
||||
{
|
||||
if (!Choices.Contains(Text, StringComparer.Ordinal))
|
||||
throw new ValidationException($"{Name} : choisissez une valeur valide.");
|
||||
return Text;
|
||||
}
|
||||
if (_type == typeof(int))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Text)) return null;
|
||||
if (int.TryParse(Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) return number;
|
||||
throw new ValidationException($"{Name} : un entier est requis.");
|
||||
}
|
||||
throw new InvalidOperationException($"Type de champ non pris en charge : {Name}.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Yavsc.Abstract.Administration;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class AdministrationPageViewModel
|
||||
{
|
||||
[ObservableProperty] public partial AdministrationSummary? Summary { get; set; }
|
||||
[ObservableProperty] public partial AdministrationRole? SelectedRole { get; set; }
|
||||
[ObservableProperty] public partial RoleMember? SelectedMember { get; set; }
|
||||
[ObservableProperty] public partial string EnrollUserId { get; set; } = "";
|
||||
[ObservableProperty] public partial AdministrationClient? SelectedClient { get; set; }
|
||||
[ObservableProperty] public partial AdministrationScope? SelectedScope { get; set; }
|
||||
[ObservableProperty] public partial MailingTemplateSettings? SelectedMailingTemplate { get; set; }
|
||||
[ObservableProperty] public partial ClientCollection SelectedCollection { get; set; }
|
||||
[ObservableProperty] public partial ClientEntryResponse? SelectedEntry { get; set; }
|
||||
[ObservableProperty] public partial ClientSecretInfo? SelectedSecret { get; set; }
|
||||
[ObservableProperty] public partial string EntryKey { get; set; } = "";
|
||||
[ObservableProperty] public partial string EntryValue { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretInput { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretDescription { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretExpiration { get; set; } = "";
|
||||
[ObservableProperty] public partial string? NewSecretValue { get; set; }
|
||||
[ObservableProperty] public partial bool ConfirmDestructiveAction { get; set; }
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ClientFields { get; set; } = [];
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ScopeFields { get; set; } = [];
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> MailingFields { get; set; } = [];
|
||||
|
||||
public ObservableCollection<AdministrationRole> Roles { get; } = [];
|
||||
public ObservableCollection<RoleMember> RoleMembers { get; } = [];
|
||||
public ObservableCollection<AdministrationClient> Clients { get; } = [];
|
||||
public ObservableCollection<AdministrationScope> Scopes { get; } = [];
|
||||
public ObservableCollection<MailingTemplateSettings> MailingTemplates { get; } = [];
|
||||
public ObservableCollection<string> MailingCriteria { get; } = [];
|
||||
public ClientCollection[] Collections { get; } = Enum.GetValues<ClientCollection>();
|
||||
|
||||
private AdministrationApiClient RequireAccess()
|
||||
{
|
||||
if (_session?.IsAdministrator != true)
|
||||
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
|
||||
return _administrationClient ?? throw new InvalidOperationException("Client API d’administration indisponible.");
|
||||
}
|
||||
|
||||
private async Task LoadToolsAsync()
|
||||
{
|
||||
var client = RequireAccess();
|
||||
var summary = await client.GetSummaryAsync();
|
||||
var roles = await client.GetRolesAsync();
|
||||
var clients = await client.GetClientsAsync();
|
||||
var scopes = await client.GetScopesAsync();
|
||||
var templates = await client.GetMailingTemplatesAsync();
|
||||
var criteria = await client.GetMailingCriteriaAsync();
|
||||
RequireAccess();
|
||||
Summary = summary;
|
||||
Replace(Roles, roles);
|
||||
Replace(Clients, clients);
|
||||
Replace(Scopes, scopes);
|
||||
Replace(MailingTemplates, templates);
|
||||
Replace(MailingCriteria, criteria);
|
||||
SelectedRole = null;
|
||||
RoleMembers.Clear();
|
||||
SelectedClient = null;
|
||||
SelectedScope = null;
|
||||
SelectedMailingTemplate = null;
|
||||
}
|
||||
|
||||
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> source)
|
||||
{
|
||||
target.Clear();
|
||||
foreach (var item in source) target.Add(item);
|
||||
}
|
||||
|
||||
private async Task RunOperationAsync(Func<AdministrationApiClient, Task> operation)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
StatusMessage = "Une opération d’administration est déjà en cours.";
|
||||
return;
|
||||
}
|
||||
IsBusy = true;
|
||||
NewSecretValue = null;
|
||||
try
|
||||
{
|
||||
var client = RequireAccess();
|
||||
await operation(client);
|
||||
RequireAccess();
|
||||
StatusMessage = "Opération effectuée.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_session?.IsAdministrator != true)
|
||||
ClearAdministrationData();
|
||||
StatusMessage = $"Administration : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
SecretInput = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RequireConfirmation()
|
||||
{
|
||||
if (!ConfirmDestructiveAction)
|
||||
throw new InvalidOperationException("Cochez la confirmation avant cette opération destructive.");
|
||||
}
|
||||
|
||||
partial void OnSelectedClientChanged(AdministrationClient? value)
|
||||
{
|
||||
ClientFields = AdministrationFieldViewModel.From(value?.Settings ?? new ClientSettings());
|
||||
SelectedEntry = null;
|
||||
SelectedSecret = null;
|
||||
NewSecretValue = null;
|
||||
SecretInput = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
}
|
||||
|
||||
partial void OnSelectedScopeChanged(AdministrationScope? value)
|
||||
=> ScopeFields = AdministrationFieldViewModel.From(value?.Settings ?? new ApiScopeSettings());
|
||||
|
||||
partial void OnSelectedMailingTemplateChanged(MailingTemplateSettings? value)
|
||||
=> MailingFields = AdministrationFieldViewModel.From(value ?? new MailingTemplateSettings());
|
||||
|
||||
[RelayCommand]
|
||||
private void NewClient()
|
||||
{
|
||||
SelectedClient = null;
|
||||
ClientFields = AdministrationFieldViewModel.From(new ClientSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewScope()
|
||||
{
|
||||
SelectedScope = null;
|
||||
ScopeFields = AdministrationFieldViewModel.From(new ApiScopeSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewMailingTemplate()
|
||||
{
|
||||
SelectedMailingTemplate = null;
|
||||
MailingFields = AdministrationFieldViewModel.From(new MailingTemplateSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task EnsureRolesAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
await client.EnsureRolesAsync();
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
private async Task ReloadRolesAsync(AdministrationApiClient client)
|
||||
{
|
||||
var selectedId = SelectedRole?.Id;
|
||||
Replace(Roles, await client.GetRolesAsync());
|
||||
SelectedRole = Roles.FirstOrDefault(role => role.Id == selectedId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task LoadRoleMembersAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var members = await client.GetRoleMembersAsync(role.Id);
|
||||
RequireAccess();
|
||||
Replace(RoleMembers, members);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task EnrollAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var id = string.IsNullOrWhiteSpace(EnrollUserId) ? SelectedUser?.UserId : EnrollUserId.Trim();
|
||||
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Sélectionnez un utilisateur ou indiquez son identifiant.");
|
||||
await client.SetRoleMembershipAsync(role.Id, id, true);
|
||||
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task FireAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var member = SelectedMember ?? throw new InvalidOperationException("Choisissez un membre.");
|
||||
await client.SetRoleMembershipAsync(role.Id, member.Id, false);
|
||||
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveClientAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveClientAsync(SelectedClient?.Id, AdministrationFieldViewModel.Build<ClientSettings>(ClientFields));
|
||||
Replace(Clients, await client.GetClientsAsync());
|
||||
SelectedClient = Clients.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteClientAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
await client.DeleteClientAsync(selected.Id);
|
||||
Replace(Clients, await client.GetClientsAsync());
|
||||
NewClient();
|
||||
});
|
||||
|
||||
private async Task ReloadClientAsync(AdministrationApiClient client, int id)
|
||||
{
|
||||
var clients = await client.GetClientsAsync();
|
||||
RequireAccess();
|
||||
Replace(Clients, clients);
|
||||
SelectedClient = Clients.Single(item => item.Id == id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task AddEntryAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Enregistrez d’abord le client.");
|
||||
if (string.IsNullOrWhiteSpace(EntryValue)) throw new InvalidOperationException("La valeur est requise.");
|
||||
await client.AddClientEntryAsync(selected.Id, SelectedCollection,
|
||||
new ClientEntry { Key = string.IsNullOrWhiteSpace(EntryKey) ? null : EntryKey, Value = EntryValue });
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
EntryValue = "";
|
||||
EntryKey = "";
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteEntryAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var entry = SelectedEntry ?? throw new InvalidOperationException("Choisissez une entrée.");
|
||||
await client.DeleteClientEntryAsync(selected.Id, entry.Collection, entry.Id);
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
});
|
||||
|
||||
private ClientSecretRequest SecretRequest()
|
||||
{
|
||||
DateTimeOffset? expiration = null;
|
||||
if (!string.IsNullOrWhiteSpace(SecretExpiration))
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(SecretExpiration, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
throw new InvalidOperationException("Expiration invalide : utilisez une date ISO 8601 avec fuseau.");
|
||||
expiration = parsed;
|
||||
}
|
||||
return new() { Value = SecretInput, Description = SecretDescription, Expiration = expiration };
|
||||
}
|
||||
|
||||
private Task CreateSecretAsync(bool replace) => RunOperationAsync(async client =>
|
||||
{
|
||||
if (replace) RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var secret = await client.CreateSecretAsync(selected.Id, SecretRequest(), replace);
|
||||
RequireAccess();
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
NewSecretValue = secret.Value;
|
||||
});
|
||||
|
||||
[RelayCommand] private Task AddSecretAsync() => CreateSecretAsync(false);
|
||||
[RelayCommand] private Task RegenerateSecretAsync() => CreateSecretAsync(true);
|
||||
[RelayCommand] private void ClearSecret() => NewSecretValue = null;
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteSecretAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var secret = SelectedSecret ?? throw new InvalidOperationException("Choisissez un secret.");
|
||||
await client.DeleteSecretAsync(selected.Id, secret.Id);
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveScopeAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveScopeAsync(SelectedScope?.Id, AdministrationFieldViewModel.Build<ApiScopeSettings>(ScopeFields));
|
||||
Replace(Scopes, await client.GetScopesAsync());
|
||||
SelectedScope = Scopes.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteScopeAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedScope ?? throw new InvalidOperationException("Choisissez un scope.");
|
||||
await client.DeleteScopeAsync(selected.Id);
|
||||
Replace(Scopes, await client.GetScopesAsync());
|
||||
NewScope();
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveMailingTemplateAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveMailingTemplateAsync(SelectedMailingTemplate?.Id,
|
||||
AdministrationFieldViewModel.Build<MailingTemplateSettings>(MailingFields));
|
||||
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
|
||||
SelectedMailingTemplate = MailingTemplates.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteMailingTemplateAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedMailingTemplate ?? throw new InvalidOperationException("Choisissez un modèle.");
|
||||
await client.DeleteMailingTemplateAsync(selected.Id);
|
||||
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
|
||||
NewMailingTemplate();
|
||||
});
|
||||
|
||||
public async Task<AdministrationTransfer> ExportTransferAsync()
|
||||
{
|
||||
var data = await RequireAccess().ExportAsync();
|
||||
RequireAccess();
|
||||
return data;
|
||||
}
|
||||
|
||||
public async Task<ImportResult> ImportTransferAsync(AdministrationTransfer transfer)
|
||||
{
|
||||
RequireConfirmation();
|
||||
var result = await RequireAccess().ImportAsync(transfer);
|
||||
ConfirmDestructiveAction = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,23 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Administration;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class AdministrationPageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly YavscApiClient? _api;
|
||||
private readonly ResourceUsageApiClient? _usageClient;
|
||||
private readonly SessionStatusViewModel? _session;
|
||||
private readonly AdministrationApiClient? _administrationClient;
|
||||
|
||||
public ObservableCollection<ResourceUsageSummary> UsageOverview { get; } = new();
|
||||
|
||||
public ResourceUsageSummary? SelectedUsage =>
|
||||
UsageOverview.FirstOrDefault(usage => usage.UserId == SelectedUser?.UserId);
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
|
@ -43,9 +54,17 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||
|
||||
public AdministrationPageViewModel(YavscApiClient? api)
|
||||
public AdministrationPageViewModel(YavscApiClient? api,
|
||||
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null,
|
||||
AdministrationApiClient? administrationClient = null)
|
||||
{
|
||||
_api = api;
|
||||
_usageClient = usageClient;
|
||||
_session = session;
|
||||
_administrationClient = administrationClient;
|
||||
NewClient();
|
||||
NewScope();
|
||||
NewMailingTemplate();
|
||||
}
|
||||
|
||||
public AdministrationPageViewModel() : this(null) { }
|
||||
|
|
@ -57,10 +76,18 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
}
|
||||
|
||||
partial void OnSelectedUserChanged(UserInfo? value) => OnPropertyChanged(nameof(SelectedUsage));
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
if (_api is null)
|
||||
ClearAdministrationData();
|
||||
if (_session?.IsAdministrator != true)
|
||||
{
|
||||
StatusMessage = "Le rôle Administrator est requis.";
|
||||
return;
|
||||
}
|
||||
if (_api is null || _usageClient is null)
|
||||
{
|
||||
StatusMessage = "Client API d’administration indisponible.";
|
||||
return;
|
||||
|
|
@ -74,21 +101,33 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString()
|
||||
: new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString();
|
||||
|
||||
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true);
|
||||
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true)
|
||||
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’utilisateurs vide.");
|
||||
var usage = await _usageClient.GetAdminOverviewAsync().ConfigureAwait(true)
|
||||
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’usages vide.");
|
||||
|
||||
Users.Clear();
|
||||
foreach (var user in users ?? [])
|
||||
if (!_session.IsAdministrator)
|
||||
{
|
||||
StatusMessage = "Le rôle Administrator est requis.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
Users.Add(user);
|
||||
}
|
||||
foreach (var item in usage)
|
||||
UsageOverview.Add(item);
|
||||
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
OnPropertyChanged(nameof(SelectedUsage));
|
||||
if (_administrationClient is not null)
|
||||
await LoadToolsAsync();
|
||||
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Users.Clear();
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
ClearAdministrationData();
|
||||
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
|
|
@ -96,4 +135,33 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAdministrationData()
|
||||
{
|
||||
Users.Clear();
|
||||
UsageOverview.Clear();
|
||||
SelectedUser = null;
|
||||
Summary = null;
|
||||
Roles.Clear();
|
||||
RoleMembers.Clear();
|
||||
Clients.Clear();
|
||||
Scopes.Clear();
|
||||
MailingTemplates.Clear();
|
||||
MailingCriteria.Clear();
|
||||
SelectedRole = null;
|
||||
SelectedMember = null;
|
||||
NewClient();
|
||||
NewScope();
|
||||
NewMailingTemplate();
|
||||
NewSecretValue = null;
|
||||
SecretInput = "";
|
||||
SecretDescription = "";
|
||||
SecretExpiration = "";
|
||||
EntryKey = "";
|
||||
EntryValue = "";
|
||||
EnrollUserId = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
OnPropertyChanged(nameof(SelectedUsage));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ using Yavsc.Api.Client;
|
|||
using Yavsc.Abstract.Files;
|
||||
using Yavsc.Server.Helpers;
|
||||
using AvaloniaEdit.Document;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -141,8 +143,87 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ReaderArticle))]
|
||||
[NotifyPropertyChangedFor(nameof(ReaderAuthor))]
|
||||
public partial BlogPostDto? SelectedPost { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsEditing { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? ReaderBaseUrl { get; private set; }
|
||||
|
||||
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
|
||||
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
|
||||
public string ReaderTitle => SelectedPost?.Title ?? string.Empty;
|
||||
public DateTime? ReaderDateModified => SelectedPost?.DateModified;
|
||||
|
||||
private readonly BlogResourceService _resources = new();
|
||||
|
||||
[RelayCommand]
|
||||
private void NewPost()
|
||||
{
|
||||
if (IsDraftModified)
|
||||
{
|
||||
this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet.");
|
||||
return;
|
||||
}
|
||||
SelectedPost = null;
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
DraftAttachments.Clear();
|
||||
_pendingAttachmentLinks.Clear();
|
||||
DraftIsPublished = false;
|
||||
MarkDraftSaved();
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void EditPost() => IsEditing = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void ReadPost()
|
||||
{
|
||||
IsEditing = false;
|
||||
UpdateReader();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenResourceAsync(string? link)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
var uri = BlogResourceService.Resolve(link ?? string.Empty,
|
||||
Settings.Authentication.Authority, ReaderAuthor);
|
||||
if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority))
|
||||
{
|
||||
this.SetInfoStatus("Détection du type de fichier...");
|
||||
var mime = await _resources.GetMimeTypeAsync(uri);
|
||||
if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS())
|
||||
{
|
||||
await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime));
|
||||
this.SetInfoStatus($"Vidéo : {mime}");
|
||||
return;
|
||||
}
|
||||
this.SetInfoStatus($"Ouverture dans le navigateur ({mime}).");
|
||||
}
|
||||
var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!)
|
||||
?? throw new InvalidOperationException("La fenêtre de l'application est indisponible.");
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(uri))
|
||||
throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
|
|
@ -258,6 +339,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
|
||||
await RefreshPostsAsync();
|
||||
UpdateReader();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -503,12 +585,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||
/// (Settings, BlogApiClient) overload below.
|
||||
/// </summary>
|
||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null,
|
||||
BlogResourceService? resources = null)
|
||||
{
|
||||
WindowTitle = "Blog";
|
||||
SettingsModel = new Settings();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||
Services = services;
|
||||
_resources = resources ?? new BlogResourceService();
|
||||
Init(settings);
|
||||
}
|
||||
|
||||
|
|
@ -523,6 +607,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||
{
|
||||
UpdateReader();
|
||||
if (!_preserveDraft)
|
||||
IsEditing = false;
|
||||
if (_preserveDraft) return;
|
||||
// Mirror the selection into the editor buffer so the
|
||||
// XAML-bound TextBox/TextEditor show the right content
|
||||
|
|
@ -542,6 +629,27 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
UpdateCommandStates();
|
||||
}
|
||||
|
||||
private void UpdateReader()
|
||||
{
|
||||
string? baseUrl = null;
|
||||
try
|
||||
{
|
||||
if (SelectedPost is not null)
|
||||
baseUrl = BlogResourceService.GetUserBaseUri(
|
||||
Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException)
|
||||
{
|
||||
this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}");
|
||||
}
|
||||
ReaderBaseUrl = baseUrl;
|
||||
OnPropertyChanged(nameof(SelectedPost));
|
||||
OnPropertyChanged(nameof(ReaderArticle));
|
||||
OnPropertyChanged(nameof(ReaderAuthor));
|
||||
OnPropertyChanged(nameof(ReaderTitle));
|
||||
OnPropertyChanged(nameof(ReaderDateModified));
|
||||
}
|
||||
|
||||
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
||||
|
||||
// Save's CanExecute depends on the buffer: the button must
|
||||
|
|
|
|||
17
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
17
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
|
||||
{
|
||||
public Uri Source { get; } = source;
|
||||
public string MimeType { get; } = mimeType;
|
||||
public PodcastApiClient? PodcastClient { get; init; }
|
||||
public PodcastResponse? Podcast { get; init; }
|
||||
public bool CanOpenExternal => Podcast is null;
|
||||
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||
public override bool CanNavigateNext { get => false; protected set { } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { } }
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Threading;
|
||||
|
|
@ -11,6 +12,7 @@ using PostIt.ViewModels.Chat;
|
|||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public class HomePageViewModel : ViewModelBase
|
||||
|
|
@ -59,6 +61,9 @@ public class HomePageViewModel : ViewModelBase
|
|||
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
||||
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
||||
() => _notificationsClient is not null);
|
||||
DismissNotificationCommand = new AsyncRelayCommand<Notification?>(DismissNotificationAsync,
|
||||
n => n is not null,
|
||||
AsyncRelayCommandOptions.AllowConcurrentExecutions);
|
||||
|
||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||
|
|
@ -68,24 +73,56 @@ public class HomePageViewModel : ViewModelBase
|
|||
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
|
||||
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
|
||||
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
|
||||
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
|
||||
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
|
||||
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
||||
OpenPodcasts = new AsyncRelayCommand(async () =>
|
||||
{
|
||||
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
|
||||
var vm = app.ServiceProvider?.GetRequiredService<PodcastsPageViewModel>()
|
||||
?? throw new InvalidOperationException("Page podcasts indisponible.");
|
||||
await app.PushPageAsync(vm);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public IAsyncRelayCommand OpenChat { get; }
|
||||
public IAsyncRelayCommand OpenPodcasts { get; }
|
||||
public bool SupportsPodcasts => Platform.IsPodcastSupported;
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenProfile { get; }
|
||||
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
|
||||
public IAsyncRelayCommand OpenAdministration { get; }
|
||||
public IAsyncRelayCommand OpenResourceUsage { get; }
|
||||
public IAsyncRelayCommand OpenActivities { get; }
|
||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
|
||||
public IAsyncRelayCommand<Notification?> DismissNotificationCommand { get; }
|
||||
|
||||
public async Task DismissNotificationAsync(Notification? notification)
|
||||
{
|
||||
if (notification is null)
|
||||
return;
|
||||
NotificationsError = null;
|
||||
try
|
||||
{
|
||||
if (SessionStatus.IsLoggedIn)
|
||||
{
|
||||
if (_notificationsClient is null)
|
||||
throw new InvalidOperationException("Notifications client is not available.");
|
||||
await _notificationsClient.DismissAsync(notification.Id);
|
||||
}
|
||||
var shown = Notifications.FirstOrDefault(n => n.Id == notification.Id);
|
||||
if (shown is not null)
|
||||
Notifications.Remove(shown);
|
||||
OnPropertyChanged(nameof(HasNoNotifications));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationsError = $"Impossible de masquer la notification : {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshNotificationsAsync()
|
||||
{
|
||||
|
|
@ -129,14 +166,27 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(SessionStatusViewModel.IsAdministrator))
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
OpenAdministration.NotifyCanExecuteChanged();
|
||||
else
|
||||
Dispatcher.UIThread.Post(OpenAdministration.NotifyCanExecuteChanged);
|
||||
}
|
||||
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
|
||||
return;
|
||||
|
||||
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
_ = RefreshNotificationsAsync();
|
||||
OnLoginChanged();
|
||||
else
|
||||
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
|
||||
Dispatcher.UIThread.Post(OnLoginChanged);
|
||||
}
|
||||
|
||||
private void OnLoginChanged()
|
||||
{
|
||||
DismissNotificationCommand.NotifyCanExecuteChanged();
|
||||
_ = RefreshNotificationsAsync();
|
||||
}
|
||||
|
||||
private async Task OpenActivitiesAsync()
|
||||
|
|
@ -267,6 +317,9 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
private async Task OpenAdministrationAsync()
|
||||
{
|
||||
if (!SessionStatus.IsAdministrator)
|
||||
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
|
||||
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
|
|
@ -283,25 +336,6 @@ public class HomePageViewModel : ViewModelBase
|
|||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
private async Task OpenResourceUsageAsync()
|
||||
{
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var resourceUsageClient = app.ServiceProvider?.GetRequiredService<ResourceUsageApiClient>();
|
||||
if (resourceUsageClient is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client usage indisponible.");
|
||||
}
|
||||
|
||||
var vm = new ResourceUsagePageViewModel(resourceUsageClient);
|
||||
await vm.InitializeAsync();
|
||||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia designer constructor. Builds a self-contained VM
|
||||
/// with a freshly-constructed Settings so the XAML preview can
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class ResourceUsagePageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ResourceUsageApiClient _client;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsAdminView { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ResourceUsageSummary? CurrentSummary { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<ResourceUsageSummary> Overview { get; set; } = new();
|
||||
|
||||
public string Heading => IsAdminView ? "Vue d'administration des usages" : "Mon usage des ressources";
|
||||
|
||||
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||
|
||||
public ResourceUsagePageViewModel(ResourceUsageApiClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => RefreshAsync();
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
Overview.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var adminOverview = await _client.GetAdminOverviewAsync().ConfigureAwait(true);
|
||||
if (adminOverview is { Count: > 0 })
|
||||
{
|
||||
foreach (var item in adminOverview)
|
||||
{
|
||||
Overview.Add(item);
|
||||
}
|
||||
|
||||
CurrentSummary = adminOverview[0];
|
||||
IsAdminView = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Non-admin users are not allowed on the admin route; fall back
|
||||
// to the current-user summary below.
|
||||
}
|
||||
|
||||
var current = await _client.GetCurrentAsync().ConfigureAwait(true);
|
||||
CurrentSummary = current;
|
||||
if (current is not null)
|
||||
{
|
||||
Overview.Clear();
|
||||
Overview.Add(current);
|
||||
}
|
||||
|
||||
IsAdminView = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CurrentSummary = null;
|
||||
Overview.Clear();
|
||||
IsAdminView = false;
|
||||
throw new InvalidOperationException($"Impossible de charger l'usage des ressources : {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
|
@ -37,6 +39,11 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
[ObservableProperty]
|
||||
public partial bool IsLoggedIn { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsAdministrator { get; private set; }
|
||||
|
||||
private int _accessVersion;
|
||||
|
||||
/// <summary>Inverse of <see cref="IsLoggedIn"/>, for XAML bindings
|
||||
/// (the banner shows the Login button when the user is logged out).
|
||||
/// Updated from <see cref="Refresh"/>.</summary>
|
||||
|
|
@ -73,12 +80,38 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
|
||||
public void Refresh()
|
||||
{
|
||||
++_accessVersion;
|
||||
IsAdministrator = false;
|
||||
var has = Api?.HasValidSession ?? false;
|
||||
IsLoggedIn = has;
|
||||
IsLoggedOut = !has;
|
||||
SessionLabel = has ? "Connecté" : "Déconnecté";
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
Refresh();
|
||||
var version = _accessVersion;
|
||||
var api = Api;
|
||||
if (!IsLoggedIn || api is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = new Uri(new Uri(api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me");
|
||||
var profile = await api.CallAsync<Yavsc.Abstract.Identity.Me>(HttpMethod.Get, endpoint.ToString());
|
||||
if (profile is null)
|
||||
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
|
||||
if (version == _accessVersion)
|
||||
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminName, StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _accessVersion)
|
||||
SessionLabel = $"Impossible de vérifier les droits : {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override the banner label with an error message. Used when
|
||||
/// an interactive login attempt fails so the operator sees
|
||||
|
|
@ -88,6 +121,8 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
/// </summary>
|
||||
public void SetError(string message)
|
||||
{
|
||||
++_accessVersion;
|
||||
IsAdministrator = false;
|
||||
IsLoggedIn = false;
|
||||
IsLoggedOut = true;
|
||||
SessionLabel = message;
|
||||
|
|
@ -118,7 +153,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
IsBusy = false;
|
||||
}
|
||||
|
||||
Refresh();
|
||||
await RefreshAsync();
|
||||
if (Api.HasValidSession)
|
||||
LoginSucceeded?.Invoke();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -14,6 +16,13 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
{
|
||||
private readonly YavscApiClient? _api;
|
||||
private readonly SessionStatusViewModel? _sessionStatus;
|
||||
private readonly ResourceUsageApiClient? _usageClient;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ResourceUsageSummary? ResourceUsage { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? UsageError { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
|
||||
|
|
@ -140,10 +149,12 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||
|
||||
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null)
|
||||
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null,
|
||||
ResourceUsageApiClient? usageClient = null)
|
||||
{
|
||||
_api = api;
|
||||
_sessionStatus = sessionStatus;
|
||||
_usageClient = usageClient;
|
||||
}
|
||||
|
||||
public UserProfilePageViewModel() : this(null, null) { }
|
||||
|
|
@ -162,6 +173,8 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
|
||||
IsBusy = true;
|
||||
IsProfileLoaded = false;
|
||||
ResourceUsage = null;
|
||||
UsageError = null;
|
||||
try
|
||||
{
|
||||
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
|
||||
|
|
@ -201,6 +214,18 @@ public partial class UserProfilePageViewModel : ViewModelBase
|
|||
}
|
||||
IsProfileLoaded = true;
|
||||
StatusMessage = "Profil chargé.";
|
||||
if (_usageClient is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ResourceUsage = await _usageClient.GetCurrentAsync()
|
||||
?? throw new InvalidOperationException("Le serveur a renvoyé un usage vide.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UsageError = $"Impossible de charger mon usage : {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
290
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal file
290
src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class PodcastsPageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly PodcastApiClient _client;
|
||||
private readonly YavscApiClient _api;
|
||||
private readonly SessionStatusViewModel _session;
|
||||
private CancellationTokenSource _pageCancellation = new();
|
||||
private PodcastBroadcaster? _broadcaster;
|
||||
private string? _ownerId;
|
||||
private string _title = "";
|
||||
private string _status = "";
|
||||
private bool _isBroadcasting;
|
||||
private bool _busy;
|
||||
private CaptureDevice? _camera;
|
||||
private CaptureDevice? _microphone;
|
||||
private PodcastResponse? _selected;
|
||||
|
||||
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
|
||||
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
|
||||
public ObservableCollection<CaptureDevice> Microphones { get; } = new();
|
||||
public bool CanBroadcast => Platform.CreatePodcastCaptureAsync is not null
|
||||
|| (!OperatingSystem.IsAndroid() && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()));
|
||||
|
||||
|
||||
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
|
||||
|
||||
|
||||
public string Status { get => _status; private set => SetProperty(ref _status, value); }
|
||||
public bool IsBroadcasting
|
||||
{
|
||||
get => _isBroadcasting;
|
||||
private set { if (SetProperty(ref _isBroadcasting, value)) UpdateCommands(); }
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _busy;
|
||||
private set { if (SetProperty(ref _busy, value)) UpdateCommands(); }
|
||||
}
|
||||
|
||||
public CaptureDevice? SelectedCamera
|
||||
{
|
||||
get => _camera;
|
||||
set { if (SetProperty(ref _camera, value)) UpdateCommands(); }
|
||||
}
|
||||
|
||||
public CaptureDevice? SelectedMicrophone
|
||||
{
|
||||
get => _microphone;
|
||||
set { if (SetProperty(ref _microphone, value)) UpdateCommands(); }
|
||||
}
|
||||
|
||||
public PodcastResponse? SelectedPodcast
|
||||
{
|
||||
get => _selected;
|
||||
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand RefreshCommand { get; }
|
||||
public IRelayCommand DetectDevicesCommand { get; }
|
||||
public IAsyncRelayCommand StartCommand { get; }
|
||||
public IAsyncRelayCommand StopCommand { get; }
|
||||
public IAsyncRelayCommand PlayCommand { get; }
|
||||
public IAsyncRelayCommand FinishCommand { get; }
|
||||
public IAsyncRelayCommand DeleteCommand { get; }
|
||||
public override bool CanNavigateNext { get => false; protected set { } }
|
||||
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
|
||||
|
||||
public PodcastsPageViewModel(PodcastApiClient client, YavscApiClient api, SessionStatusViewModel session)
|
||||
{
|
||||
_client = client;
|
||||
_api = api;
|
||||
_session = session;
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
|
||||
StartCommand = new AsyncRelayCommand(StartAsync,
|
||||
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
|
||||
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
|
||||
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
|
||||
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
|
||||
FinishCommand = new AsyncRelayCommand(FinishAsync,
|
||||
() => SelectedPodcast is { State: not PodcastState.Completed } p
|
||||
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
|
||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
|
||||
() => SelectedPodcast is { State: not PodcastState.Live } p
|
||||
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
|
||||
}
|
||||
|
||||
private void UpdateCommands()
|
||||
{
|
||||
RefreshCommand.NotifyCanExecuteChanged();
|
||||
DetectDevicesCommand.NotifyCanExecuteChanged();
|
||||
StartCommand.NotifyCanExecuteChanged();
|
||||
StopCommand.NotifyCanExecuteChanged();
|
||||
PlayCommand.NotifyCanExecuteChanged();
|
||||
FinishCommand.NotifyCanExecuteChanged();
|
||||
DeleteCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(CanNavigatePrevious));
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var profileUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me");
|
||||
_ownerId = (await _api.CallAsync<Me>(HttpMethod.Get, profileUrl.AbsoluteUri)).UserId;
|
||||
var podcasts = await _client.ListAsync();
|
||||
Podcasts.Clear();
|
||||
foreach (var podcast in podcasts) Podcasts.Add(podcast);
|
||||
Status = "Directs et replays reserves aux utilisateurs connectes.";
|
||||
}
|
||||
catch (Exception ex) { Status = $"Chargement impossible : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
public async Task ActivateAsync()
|
||||
{
|
||||
if (_pageCancellation.IsCancellationRequested)
|
||||
{
|
||||
_pageCancellation.Dispose();
|
||||
_pageCancellation = new();
|
||||
}
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
public async Task DeactivateAsync()
|
||||
{
|
||||
_pageCancellation.Cancel();
|
||||
if (_broadcaster is not null) await StopAsync();
|
||||
}
|
||||
|
||||
private void DetectDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
Cameras.Clear();
|
||||
Microphones.Clear();
|
||||
if (Platform.GetPodcastCaptureDevices is { } getDevices)
|
||||
{
|
||||
var devices = getDevices();
|
||||
foreach (var item in devices.Cameras) Cameras.Add(item);
|
||||
foreach (var item in devices.Microphones) Microphones.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
Core.Initialize();
|
||||
using var vlc = CameraStreamer.CreatePreferredLibVlc();
|
||||
var capture = new CameraStreamer(vlc);
|
||||
foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value));
|
||||
Microphones.Add(new("Sans microphone", ""));
|
||||
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
|
||||
}
|
||||
SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
|
||||
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
|
||||
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
|
||||
}
|
||||
catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; }
|
||||
}
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera.");
|
||||
var camera = SelectedCamera.Value;
|
||||
var microphone = string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value;
|
||||
if (Platform.RequestPodcastPermissionsAsync is { } requestPermissions)
|
||||
await requestPermissions(microphone, _pageCancellation.Token);
|
||||
_broadcaster = Platform.CreatePodcastCaptureAsync is { } createCapture
|
||||
? await PodcastBroadcaster.StartAsync(_client, Title,
|
||||
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
|
||||
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
|
||||
_broadcaster.Progress += OnProgress;
|
||||
_session.LogoutCompleted += OnLogout;
|
||||
IsBroadcasting = true;
|
||||
Status = "Capture en cours ; attente du premier segment HLS.";
|
||||
_ = ObserveAsync(_broadcaster);
|
||||
}
|
||||
catch (Exception ex) { Status = $"Demarrage impossible : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnProgress(PodcastResponse podcast)
|
||||
=> Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_broadcaster?.Podcast.Id == podcast.Id)
|
||||
Status = $"En direct : {podcast.SegmentCount} segments, {podcast.DurationSeconds:0} s envoyees.";
|
||||
});
|
||||
|
||||
private async Task ObserveAsync(PodcastBroadcaster broadcaster)
|
||||
{
|
||||
try
|
||||
{
|
||||
await broadcaster.Completion;
|
||||
Status = broadcaster.Podcast.SegmentCount > 0
|
||||
? "Direct termine. Le replay est disponible."
|
||||
: "Direct termine sans segment recu : aucun replay a lire.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status = $"Diffusion interrompue : {ex.Message}. Les segments recus restent disponibles ; les segments non envoyes sont conserves localement.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
broadcaster.Progress -= OnProgress;
|
||||
_session.LogoutCompleted -= OnLogout;
|
||||
try { await broadcaster.DisposeAsync(); }
|
||||
catch (Exception ex) { Status += $" Nettoyage : {ex.Message}"; }
|
||||
_broadcaster = null;
|
||||
IsBroadcasting = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (_broadcaster is not null) await _broadcaster.StopAsync();
|
||||
}
|
||||
catch (Exception ex) { Status = $"Arret du direct : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private async void OnLogout()
|
||||
{
|
||||
// Releasing capture is required even after the HTTP credentials have been cleared.
|
||||
await StopAsync();
|
||||
}
|
||||
|
||||
private async Task PlayAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
|
||||
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
|
||||
var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8");
|
||||
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
|
||||
{
|
||||
PodcastClient = _client, Podcast = podcast
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { Status = $"Lecture impossible : {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async Task FinishAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
|
||||
await _client.CompleteAsync(podcast.Id);
|
||||
await RefreshAsync();
|
||||
}
|
||||
catch (Exception ex) { Status = $"Finalisation impossible : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
|
||||
await _client.DeleteAsync(podcast.Id);
|
||||
SelectedPodcast = null;
|
||||
await RefreshAsync();
|
||||
}
|
||||
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
<ContentPage
|
||||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||
x:Name="BlogsPageRoot"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
xmlns:services="using:PostIt.Services"
|
||||
xmlns:models="using:Yavsc.Blogspot"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
xmlns:views="using:PostIt.Views.Blogs"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
|
|
@ -17,15 +17,8 @@
|
|||
<Design.DataContext>
|
||||
<vm:BlogsViewModel />
|
||||
</Design.DataContext>
|
||||
<ContentPage.Styles>
|
||||
<Style Selector="CommandBarButton.modified">
|
||||
<Setter Property="Background" Value="#FFF0C2" />
|
||||
<Setter Property="Foreground" Value="#663C00" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
</ContentPage.Styles>
|
||||
<Grid
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
<Grid x:Name="MainGrid"
|
||||
RowDefinitions="Auto,Auto,*,4,2*,Auto"
|
||||
Margin="5,5,10,5"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
|
|
@ -36,23 +29,31 @@
|
|||
<TextBlock
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Text="Blog Posts" />
|
||||
Text="Billets de blog" />
|
||||
<TextBox
|
||||
HorizontalAlignment="Right"
|
||||
PlaceholderText="Filter by title or author..."
|
||||
PlaceholderText="Rechercher un titre ou un auteur..."
|
||||
Text="{Binding SearchText, Mode=TwoWay}" />
|
||||
</WrapPanel>
|
||||
<CommandBar x:Name="BlogsCommandBar"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
||||
<CommandBar.PrimaryCommands>
|
||||
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
|
||||
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding RefreshCommand}" Label="Actualiser">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource RefreshIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
|
||||
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
|
||||
IsVisible="{Binding IsEditing}" Label="Lire" />
|
||||
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" x:Name="EditPostButton" Command="{Binding EditPostCommand}"
|
||||
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
IsVisible="{Binding !IsEditing}" Label="Modifier" />
|
||||
<CommandBarButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
x:Name="SaveButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes.modified="{Binding IsDraftModified}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
|
|
@ -61,13 +62,15 @@
|
|||
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
|
||||
<CommandBarButton DataContext="{Binding #BlogsPageRoot.DataContext}" Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource DeleteIcon}" />
|
||||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
x:Name="AddAttachmentButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Click="AddAttachment_Click"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Ajouter fichiers">
|
||||
|
|
@ -76,7 +79,9 @@
|
|||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
x:Name="ManageAclButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding ManageAclAsync}"
|
||||
Label="ACL">
|
||||
<CommandBarButton.Icon>
|
||||
|
|
@ -84,6 +89,7 @@
|
|||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
x:Name="OpenCirclesButton"
|
||||
Command="{Binding OpenCirclesAsync}"
|
||||
Label="Mes cercles">
|
||||
|
|
@ -92,6 +98,8 @@
|
|||
</CommandBarButton.Icon>
|
||||
</CommandBarButton>
|
||||
<CommandBarToggleButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding TogglePublishCommand}"
|
||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||
Label="Publié">
|
||||
|
|
@ -99,13 +107,15 @@
|
|||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton DataContext="{Binding #BlogsPageRoot.DataContext}" IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarButton
|
||||
DataContext="{Binding #BlogsPageRoot.DataContext}"
|
||||
x:Name="OpenSignatureDevButton"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding OpenSignatureDevAsync}"
|
||||
Label="[DEV] Signature">
|
||||
<CommandBarButton.Icon>
|
||||
|
|
@ -115,14 +125,9 @@
|
|||
</CommandBar.PrimaryCommands>
|
||||
</CommandBar>
|
||||
|
||||
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="4" />
|
||||
<RowDefinition Height="2*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
<Border x:Name="PostsListBorder"
|
||||
Grid.Row="2"
|
||||
Margin="12"
|
||||
Padding="8"
|
||||
BorderBrush="Gray"
|
||||
BorderThickness="1">
|
||||
|
|
@ -168,13 +173,16 @@
|
|||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<GridSplitter
|
||||
Grid.Row="1"
|
||||
<GridSplitter x:Name="PostsSplitter"
|
||||
Grid.Row="3"
|
||||
Margin="12,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
ResizeDirection="Rows" />
|
||||
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
<Grid x:Name="PostEditor"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Grid.Row="4"
|
||||
Margin="12"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RowSpacing="10">
|
||||
|
|
@ -183,7 +191,6 @@
|
|||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
|
|
@ -226,9 +233,33 @@
|
|||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid x:Name="PostReader" Grid.Row="4" Margin="12" IsVisible="{Binding !IsEditing}"
|
||||
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
|
||||
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" />
|
||||
<WrapPanel Grid.Row="1" Orientation="Horizontal"
|
||||
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
|
||||
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
|
||||
Opacity="0.65" />
|
||||
</WrapPanel>
|
||||
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
|
||||
AssetPathRoot="{Binding ReaderBaseUrl}"
|
||||
Markdown="{Binding ReaderArticle}"
|
||||
SaveScrollValueWhenContentUpdated="False">
|
||||
<reader:MarkdownScrollViewer.Plugins>
|
||||
<reader:MdAvPlugins>
|
||||
<reader:MdAvPlugins.PathResolver>
|
||||
<services:BlogImageResolver />
|
||||
</reader:MdAvPlugins.PathResolver>
|
||||
</reader:MdAvPlugins>
|
||||
</reader:MarkdownScrollViewer.Plugins>
|
||||
</reader:MarkdownScrollViewer>
|
||||
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Choisissez un billet dans la liste pour le lire."
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
<postitControls:StatusBar Grid.Row="5" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,132 @@
|
|||
|
||||
using System.IO;
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Markdown.Avalonia;
|
||||
|
||||
namespace PostIt.Views.Blogs;
|
||||
|
||||
public partial class BlogsPage : ContentPage
|
||||
{
|
||||
private static readonly RelayCommand<string?> DisabledLinkCommand =
|
||||
new(static _ => { }, static _ => false);
|
||||
private bool? _isWideLayout;
|
||||
private ViewModels.BlogsViewModel? _observedViewModel;
|
||||
|
||||
public BlogsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
SizeChanged += (_, _) =>
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
UpdateResponsiveLayout();
|
||||
});
|
||||
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
|
||||
AttachedToVisualTree += (_, _) => UpdateHyperlinkCommand();
|
||||
DetachedFromVisualTree += (_, _) =>
|
||||
{
|
||||
if (_observedViewModel is not null)
|
||||
_observedViewModel.PropertyChanged -= ViewModel_PropertyChanged;
|
||||
_observedViewModel = null;
|
||||
};
|
||||
UpdateHyperlinkCommand();
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
private void UpdateResponsiveLayout()
|
||||
{
|
||||
var size = TopLevel.GetTopLevel(this)?.ClientSize ?? Bounds.Size;
|
||||
if (size.Width <= 0 || size.Height <= 0)
|
||||
return;
|
||||
|
||||
// Use the window size so compacting the navigation bar cannot change the orientation.
|
||||
var isWide = size.Width >= size.Height;
|
||||
if (_isWideLayout == isWide)
|
||||
return;
|
||||
_isWideLayout = isWide;
|
||||
|
||||
MainGrid.RowDefinitions = new RowDefinitions(isWide ? "Auto,Auto,*,Auto" : "Auto,Auto,*,4,2*,Auto");
|
||||
MainGrid.ColumnDefinitions = new ColumnDefinitions(isWide ? "2*,4,*" : "*");
|
||||
var sidebarColumn = isWide ? 2 : 0;
|
||||
SetArea(BlogsHeaderPanel, 0, sidebarColumn);
|
||||
SetArea(BlogsCommandBar, 1, sidebarColumn);
|
||||
SetArea(PostsListBorder, 2, sidebarColumn);
|
||||
SetArea(PostsSplitter, isWide ? 0 : 3, isWide ? 1 : 0, isWide ? 3 : 1);
|
||||
SetArea(PostReader, isWide ? 0 : 4, 0, isWide ? 3 : 1);
|
||||
SetArea(PostEditor, isWide ? 0 : 4, 0, isWide ? 3 : 1);
|
||||
SetArea(StatusBar, isWide ? 3 : 5, 0, columnSpan: isWide ? 3 : 1);
|
||||
PostsSplitter.ResizeDirection = isWide ? GridResizeDirection.Columns : GridResizeDirection.Rows;
|
||||
PostsSplitter.HorizontalAlignment = isWide ? HorizontalAlignment.Center : HorizontalAlignment.Stretch;
|
||||
PostsSplitter.VerticalAlignment = isWide ? VerticalAlignment.Stretch : VerticalAlignment.Center;
|
||||
PostsSplitter.Margin = isWide ? new Thickness(0, 12) : new Thickness(12, 0);
|
||||
NavigationPage.SetBarHeightOverride(this, isWide ? 36 : null);
|
||||
}
|
||||
|
||||
private static void SetArea(Control control, int row, int column, int rowSpan = 1, int columnSpan = 1)
|
||||
{
|
||||
Grid.SetRow(control, row);
|
||||
Grid.SetColumn(control, column);
|
||||
Grid.SetRowSpan(control, rowSpan);
|
||||
Grid.SetColumnSpan(control, columnSpan);
|
||||
}
|
||||
|
||||
private void UpdateHyperlinkCommand()
|
||||
{
|
||||
if (_observedViewModel is not null)
|
||||
_observedViewModel.PropertyChanged -= ViewModel_PropertyChanged;
|
||||
|
||||
_observedViewModel = DataContext as ViewModels.BlogsViewModel;
|
||||
if (_observedViewModel is not null)
|
||||
_observedViewModel.PropertyChanged += ViewModel_PropertyChanged;
|
||||
|
||||
if (ArticleReader.Engine is IMarkdownEngine2 engine)
|
||||
{
|
||||
if (_observedViewModel is not null)
|
||||
engine.HyperlinkCommand = _observedViewModel.OpenResourceCommand;
|
||||
else
|
||||
engine.HyperlinkCommand = DisabledLinkCommand;
|
||||
}
|
||||
|
||||
UpdateSaveButtonHighlight();
|
||||
}
|
||||
|
||||
private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ViewModels.BlogsViewModel.IsDraftModified))
|
||||
UpdateSaveButtonHighlight();
|
||||
}
|
||||
|
||||
private void UpdateSaveButtonHighlight()
|
||||
{
|
||||
if (SaveButton is null)
|
||||
return;
|
||||
|
||||
if (_observedViewModel?.IsDraftModified == true)
|
||||
{
|
||||
SaveButton.FontWeight = FontWeight.Bold;
|
||||
SaveButton.Background = new SolidColorBrush(Color.Parse("#FFF0C2"));
|
||||
SaveButton.Foreground = new SolidColorBrush(Color.Parse("#663C00"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveButton.FontWeight = FontWeight.Normal;
|
||||
SaveButton.ClearValue(TemplatedControl.BackgroundProperty);
|
||||
SaveButton.ClearValue(TemplatedControl.ForegroundProperty);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
if (DataContext is ViewModels.BlogsViewModel vm)
|
||||
{
|
||||
if (!vm.IsLoaded)
|
||||
{
|
||||
vm.RefreshAsync().Wait();
|
||||
}
|
||||
await vm.InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
18
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
18
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<ContentPage
|
||||
x:Class="PostIt.Views.Blogs.VideoPage"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
x:DataType="vm:VideoPageViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
||||
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
||||
<Border Grid.Row="1" Background="Black" BorderThickness="1" BorderBrush="Green">
|
||||
<ContentControl x:Name="VideoSurface" />
|
||||
</Border>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
||||
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
||||
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" IsVisible="{Binding CanOpenExternal}" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
177
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
177
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LibVLCSharp.Shared;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
|
||||
namespace PostIt.Views.Blogs;
|
||||
|
||||
public partial class VideoPage : ContentPage
|
||||
{
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _player;
|
||||
private PodcastMediaInput? _podcastInput;
|
||||
|
||||
public VideoPage() => InitializeComponent();
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
Platform.PlaybackSuspending += SuspendPlayback;
|
||||
Platform.PlaybackResuming += ResumePlayback;
|
||||
InitializePlayer();
|
||||
}
|
||||
|
||||
private void InitializePlayer()
|
||||
{
|
||||
ReleasePlayer();
|
||||
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_libVlc = new LibVLC();
|
||||
_player = new MediaPlayer(_libVlc);
|
||||
_player.EncounteredError += Player_EncounteredError;
|
||||
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
|
||||
{
|
||||
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
|
||||
_podcastInput.Failed += PodcastFailed;
|
||||
}
|
||||
using var media = _podcastInput is null
|
||||
? new Media(_libVlc, vm.Source)
|
||||
: new Media(_libVlc, _podcastInput);
|
||||
if (_podcastInput is not null)
|
||||
media.AddOption(":demux=ts");
|
||||
var surface = Platform.CreateVideoSurface?.Invoke(_player)
|
||||
?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
|
||||
VideoSurface.Content = surface;
|
||||
var player = _player;
|
||||
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ReferenceEquals(_player, player) && surface is LibVLCSharp.Avalonia.VideoView desktopVideo)
|
||||
desktopVideo.TryUpdateNativeControlPosition();
|
||||
}, DispatcherPriority.Loaded);
|
||||
PlayButton.IsEnabled = true;
|
||||
PlaybackStatus.Text = vm.MimeType;
|
||||
if (!_player.Play(media))
|
||||
PlaybackStatus.Text = vm.Podcast is null
|
||||
? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."
|
||||
: "Impossible de demarrer la lecture du podcast.";
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
|
||||
{
|
||||
ReleasePlayer();
|
||||
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
||||
{
|
||||
Platform.PlaybackSuspending -= SuspendPlayback;
|
||||
Platform.PlaybackResuming -= ResumePlayback;
|
||||
ReleasePlayer();
|
||||
base.OnNavigatedFrom(e);
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
Platform.PlaybackSuspending -= SuspendPlayback;
|
||||
Platform.PlaybackResuming -= ResumePlayback;
|
||||
ReleasePlayer();
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
private void ReleasePlayer()
|
||||
{
|
||||
PlayButton.IsEnabled = false;
|
||||
_podcastInput?.Cancel();
|
||||
if (VideoSurface.Content is LibVLCSharp.Avalonia.VideoView desktopVideo)
|
||||
desktopVideo.MediaPlayer = null;
|
||||
if (VideoSurface.Content is IDisposable nativeSurface)
|
||||
nativeSurface.Dispose();
|
||||
VideoSurface.Content = null;
|
||||
if (_player is not null)
|
||||
{
|
||||
_player.EncounteredError -= Player_EncounteredError;
|
||||
_player.Stop();
|
||||
_player.Dispose();
|
||||
_player = null;
|
||||
}
|
||||
if (_podcastInput is not null)
|
||||
{
|
||||
_podcastInput.Failed -= PodcastFailed;
|
||||
_podcastInput.Dispose();
|
||||
_podcastInput = null;
|
||||
}
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
}
|
||||
|
||||
private void SuspendPlayback()
|
||||
{
|
||||
ReleasePlayer();
|
||||
PlaybackStatus.Text = "Lecture suspendue.";
|
||||
}
|
||||
|
||||
private void ResumePlayback() => InitializePlayer();
|
||||
|
||||
private void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message);
|
||||
|
||||
private static bool CanLaunchExternally(Uri source)
|
||||
=> source is { IsAbsoluteUri: true }
|
||||
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(source.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !source.AbsolutePath.Contains("/api/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void Player_EncounteredError(object? sender, EventArgs e)
|
||||
=> Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!ReferenceEquals(sender, _player) || _player is null)
|
||||
return;
|
||||
|
||||
PlaybackStatus.Text = _podcastInput is null
|
||||
? "Impossible de lire cette vidéo."
|
||||
: "Impossible de lire le podcast.";
|
||||
});
|
||||
|
||||
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_player is null)
|
||||
return;
|
||||
if (_player.IsPlaying)
|
||||
_player.Pause();
|
||||
else if (!_player.Play())
|
||||
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
|
||||
}
|
||||
|
||||
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not VideoPageViewModel vm)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (!CanLaunchExternally(vm.Source))
|
||||
{
|
||||
PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur.";
|
||||
return;
|
||||
}
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this)
|
||||
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
||||
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,70 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:resources="using:Yavsc.Abstract.Resources"
|
||||
xmlns:admin="using:Yavsc.Abstract.Administration"
|
||||
x:Class="PostIt.Views.AdministrationPage"
|
||||
x:DataType="vm:AdministrationPageViewModel"
|
||||
Header="Administration">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
|
||||
<ContentPage.DataTemplates>
|
||||
<DataTemplate DataType="{x:Type vm:AdministrationFieldViewModel}" x:DataType="vm:AdministrationFieldViewModel">
|
||||
<StackPanel Spacing="4" Margin="0,0,0,10">
|
||||
<TextBlock Text="{Binding Name}" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding Text, Mode=TwoWay}" IsVisible="{Binding IsText}"
|
||||
AcceptsReturn="{Binding IsMultiline}" TextWrapping="Wrap"/>
|
||||
<CheckBox IsChecked="{Binding Checked, Mode=TwoWay}" IsVisible="{Binding IsBoolean}"/>
|
||||
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding Text, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsChoice}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationRole}" x:DataType="admin:AdministrationRole">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
<TextBlock Text="{Binding UserCount, StringFormat='({0} membres)'}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:RoleMember}" x:DataType="admin:RoleMember">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Id}"/>
|
||||
<TextBlock Text="{Binding Email}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationClient}" x:DataType="admin:AdministrationClient">
|
||||
<TextBlock Text="{Binding Settings.ClientId}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationScope}" x:DataType="admin:AdministrationScope">
|
||||
<TextBlock Text="{Binding Settings.Name}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:MailingTemplateSettings}" x:DataType="admin:MailingTemplateSettings">
|
||||
<TextBlock Text="{Binding Id}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:ClientEntryResponse}" x:DataType="admin:ClientEntryResponse">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Collection}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Key}"/>
|
||||
<TextBlock Text="{Binding Value}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:ClientSecretInfo}" x:DataType="admin:ClientSecretInfo">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Description}"/>
|
||||
<TextBlock Text="{Binding Created, StringFormat='Création : {0:u}'}"/>
|
||||
<TextBlock Text="{Binding Expiration, StringFormat='Expiration : {0:u}'}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ContentPage.DataTemplates>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="12">
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Administration"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="8">
|
||||
<TextBlock Text="{Binding Summary.UserCount, StringFormat='Utilisateurs enregistrés : {0}'}"/>
|
||||
<TextBlock Text="{Binding Summary.AdministratorCount, StringFormat='Administrateurs : {0}'}"/>
|
||||
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
|
||||
PlaceholderText="Rechercher un utilisateur..."/>
|
||||
<Button Content="Actualiser"
|
||||
|
|
@ -22,38 +75,185 @@
|
|||
Height="8"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"/>
|
||||
<CheckBox Content="Je confirme la prochaine suppression, exclusion, régénération ou import."
|
||||
IsChecked="{Binding ConfirmDestructiveAction, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
|
||||
<ListBox ItemsSource="{Binding FilteredUsers}"
|
||||
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||
<TabControl Grid.Row="2" IsEnabled="{Binding !IsBusy}">
|
||||
<TabControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</TabControl.ItemsPanel>
|
||||
<TabItem Header="Utilisateurs">
|
||||
<Grid ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
|
||||
<ListBox ItemsSource="{Binding FilteredUsers}"
|
||||
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<ScrollViewer Grid.Column="1">
|
||||
<Border Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
|
||||
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||
<StackPanel DataContext="{Binding SelectedUsage}" Spacing="4">
|
||||
<TextBlock Text="Usage des ressources" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
||||
<TextBlock Text="{Binding Currency}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
|
||||
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
<TabItem Header="Usages">
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding UsageOverview}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="resources:ResourceUsageSummary">
|
||||
<Border Margin="0,0,0,8" Padding="12" CornerRadius="8"
|
||||
BorderBrush="#D0D7DE" BorderThickness="1">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding UserId}"/>
|
||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
||||
<TextBlock Text="{Binding Currency}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Rôles">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Rôles et affectations" FontSize="18" FontWeight="Bold"/>
|
||||
<Button Content="Initialiser les rôles standards" Command="{Binding EnsureRolesCommand}"/>
|
||||
<ComboBox ItemsSource="{Binding Roles}" SelectedItem="{Binding SelectedRole, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<Button Content="Afficher les membres" Command="{Binding LoadRoleMembersCommand}"/>
|
||||
<ListBox ItemsSource="{Binding RoleMembers}" SelectedItem="{Binding SelectedMember, Mode=TwoWay}" Height="200"/>
|
||||
<TextBlock Text="Identifiant à inscrire (ou utilisateur sélectionné dans l’onglet Utilisateurs)" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding EnrollUserId, Mode=TwoWay}" PlaceholderText="Identifiant utilisateur"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Inscrire" Command="{Binding EnrollCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Retirer le membre sélectionné" Command="{Binding FireCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding Summary.Assembly}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding Summary.Runtime}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Clients OIDC">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding Clients}" SelectedItem="{Binding SelectedClient, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un client"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewClientCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveClientCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteClientCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<Expander Header="Paramètres du client" IsExpanded="True" HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding ClientFields}"/>
|
||||
</Expander>
|
||||
<TextBlock Text="Collections" FontSize="18" FontWeight="Bold"/>
|
||||
<ListBox ItemsSource="{Binding SelectedClient.Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}" Height="200"/>
|
||||
<ComboBox ItemsSource="{Binding Collections}" SelectedItem="{Binding SelectedCollection, Mode=TwoWay}"/>
|
||||
<TextBox Text="{Binding EntryKey, Mode=TwoWay}" PlaceholderText="Clé (claims et propriétés)"/>
|
||||
<TextBox Text="{Binding EntryValue, Mode=TwoWay}" PlaceholderText="Valeur"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Ajouter l’entrée" Command="{Binding AddEntryCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer l’entrée sélectionnée" Command="{Binding DeleteEntryCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Secrets" FontSize="18" FontWeight="Bold"/>
|
||||
<ListBox ItemsSource="{Binding SelectedClient.Secrets}" SelectedItem="{Binding SelectedSecret, Mode=TwoWay}" Height="150"/>
|
||||
<TextBox Text="{Binding SecretInput, Mode=TwoWay}" PasswordChar="●"
|
||||
PlaceholderText="Secret fourni (vide : générer un secret)"/>
|
||||
<TextBox Text="{Binding SecretDescription, Mode=TwoWay}" PlaceholderText="Description"/>
|
||||
<TextBox Text="{Binding SecretExpiration, Mode=TwoWay}" PlaceholderText="Expiration ISO 8601 (facultative)"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Ajouter un secret" Command="{Binding AddSecretCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Régénérer et remplacer tous les secrets" Command="{Binding RegenerateSecretCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer le secret sélectionné" Command="{Binding DeleteSecretCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Nouveau secret : copiez-le maintenant. Il ne sera plus consultable ensuite." TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding NewSecretValue}" IsReadOnly="True"/>
|
||||
<Button Content="Masquer le nouveau secret" Command="{Binding ClearSecretCommand}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Scopes API">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding Scopes}" SelectedItem="{Binding SelectedScope, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un scope"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewScopeCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveScopeCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteScopeCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<ItemsControl ItemsSource="{Binding ScopeFields}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Courriels">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding MailingTemplates}" SelectedItem="{Binding SelectedMailingTemplate, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un modèle"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Critères de diffusion disponibles (champ Id du modèle)" TextWrapping="Wrap"/>
|
||||
<ItemsControl ItemsSource="{Binding MailingCriteria}"/>
|
||||
<ItemsControl ItemsSource="{Binding MailingFields}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Données">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Transferts de données métier" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock Text="Comptes (données métier uniquement), billets et activités. Aucun mot de passe, jeton, rôle ou secret d’authentification n’est transféré."
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Text="L’import met à jour les identifiants existants et crée les nouvelles lignes, en une transaction. Les cercles référencés doivent déjà exister. Les nouveaux comptes n’ont pas de mot de passe."
|
||||
TextWrapping="Wrap"/>
|
||||
<Button Content="Exporter vers un fichier JSON" Click="Export_Click"/>
|
||||
<Button Content="Importer un fichier JSON (16 Mo maximum)" Click="Import_Click"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
using Avalonia.Controls;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc.Abstract.Administration;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -7,5 +15,75 @@ public partial class AdministrationPage : ContentPage
|
|||
public AdministrationPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
DetachedFromVisualTree += (_, _) =>
|
||||
{
|
||||
if (DataContext is AdministrationPageViewModel vm)
|
||||
{
|
||||
vm.NewSecretValue = null;
|
||||
vm.SecretInput = "";
|
||||
vm.ConfirmDestructiveAction = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async void Export_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
|
||||
vm.IsBusy = true;
|
||||
try
|
||||
{
|
||||
var data = await vm.ExportTransferAsync();
|
||||
var content = JsonSerializer.SerializeToUtf8Bytes(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
if (!await FileSaveHelpers.SaveAsync("yavsc-business-data.json", "json", content)) return;
|
||||
vm.StatusMessage = "Données métier exportées.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.StatusMessage = $"Export impossible : {ex.Message}";
|
||||
}
|
||||
finally { vm.IsBusy = false; }
|
||||
}
|
||||
|
||||
private async void Import_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
|
||||
vm.IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (!vm.ConfirmDestructiveAction) throw new InvalidOperationException("Confirmez l’import avant de choisir un fichier.");
|
||||
var topLevel = TopLevel.GetTopLevel(this) ?? throw new InvalidOperationException("Sélecteur de fichiers indisponible.");
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Importer les données métier", AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }]
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
const int limit = 16 * 1024 * 1024;
|
||||
await using var stream = await files[0].OpenReadAsync();
|
||||
using var data = new MemoryStream();
|
||||
var buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = await stream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
if (data.Length + count > limit) throw new InvalidOperationException("Le fichier dépasse 16 Mo.");
|
||||
await data.WriteAsync(buffer.AsMemory(0, count));
|
||||
}
|
||||
data.Position = 0;
|
||||
var transfer = await JsonSerializer.DeserializeAsync<AdministrationTransfer>(data,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidOperationException("Le fichier est vide.");
|
||||
var result = await vm.ImportTransferAsync(transfer);
|
||||
await vm.RefreshAsync();
|
||||
vm.StatusMessage = $"Import effectué : {result.Users} comptes, {result.Blogs} billets, {result.Activities} activités. {vm.StatusMessage}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.StatusMessage = $"Import impossible : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
vm.ConfirmDestructiveAction = false;
|
||||
vm.IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,15 @@
|
|||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||
Content="{Binding click_action, TargetNullValue=Fermer}"
|
||||
ToolTip.Tip="Masquer cette notification"
|
||||
Command="{Binding $parent[ItemsControl].((vm:HomePageViewModel)DataContext).DismissNotificationCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
|
@ -50,6 +57,11 @@
|
|||
Command="{Binding OpenChat}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Podcasts : directs et replays"
|
||||
Command="{Binding OpenPodcasts}"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="{Binding SupportsPodcasts}"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Open Blog Interface"
|
||||
Command="{Binding OpenBlogs}"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
@ -85,11 +97,7 @@
|
|||
<Button Content="Administration"
|
||||
Command="{Binding OpenAdministration}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mon usage / administration"
|
||||
Command="{Binding OpenResourceUsage}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
IsVisible="{Binding SessionStatus.IsAdministrator}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:resources="using:Yavsc.Abstract.Resources"
|
||||
x:Class="PostIt.Views.ResourceUsagePage"
|
||||
x:DataType="vm:ResourceUsagePageViewModel"
|
||||
Header="Usage des ressources">
|
||||
|
||||
<StackPanel Margin="16" Spacing="12">
|
||||
<TextBlock Text="{Binding Heading}"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"/>
|
||||
|
||||
<Button Content="Actualiser"
|
||||
Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
|
||||
<ProgressBar IsVisible="{Binding IsBusy}"
|
||||
IsIndeterminate="True"
|
||||
Height="8"/>
|
||||
|
||||
<StackPanel IsVisible="{Binding !IsAdminView}">
|
||||
<TextBlock Text="Résumé personnel" FontWeight="Bold"/>
|
||||
<StackPanel DataContext="{Binding CurrentSummary}" Spacing="4">
|
||||
<TextBlock Text="{Binding UserName, StringFormat='Utilisateur : {0}'}"/>
|
||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Overview}"
|
||||
IsVisible="{Binding IsAdminView}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="resources:ResourceUsageSummary">
|
||||
<Border Margin="0,0,0,8"
|
||||
Padding="12"
|
||||
CornerRadius="8"
|
||||
BorderBrush="#D0D7DE"
|
||||
BorderThickness="1">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName}"
|
||||
FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ContentPage>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class ResourceUsagePage : ContentPage
|
||||
{
|
||||
public ResourceUsagePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,13 @@
|
|||
xmlns:identity="using:Yavsc.Abstract.Identity"
|
||||
x:Class="PostIt.Views.UserProfilePage"
|
||||
x:DataType="vm:UserProfilePageViewModel"
|
||||
Header="Mon compte">
|
||||
Header="Mon profil">
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="16" Spacing="16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Gérer votre compte"
|
||||
<TextBlock Text="Mon profil"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Paramètres de votre compte"
|
||||
|
|
@ -27,6 +27,21 @@
|
|||
IsIndeterminate="True"
|
||||
Height="8"/>
|
||||
|
||||
<Border Padding="16" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Mon usage des ressources" FontSize="18" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding UsageError}" TextWrapping="Wrap"/>
|
||||
<StackPanel DataContext="{Binding ResourceUsage}" Spacing="4">
|
||||
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
|
||||
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
|
||||
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
|
||||
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
|
||||
<TextBlock Text="{Binding Currency}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="16"
|
||||
BorderBrush="#D0D7DE"
|
||||
BorderThickness="1"
|
||||
|
|
|
|||
56
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal file
56
src/PostIt/PostIt/Views/PodcastsPage.axaml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:services="using:PostIt.Services"
|
||||
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
|
||||
x:Class="PostIt.Views.PodcastsPage"
|
||||
x:DataType="vm:PodcastsPageViewModel"
|
||||
Header="Podcasts">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
|
||||
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
|
||||
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
|
||||
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
|
||||
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
|
||||
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
|
||||
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
|
||||
</StackPanel>
|
||||
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
|
||||
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
|
||||
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"
|
||||
SelectionChanged="PodcastSelectionChanged">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="streaming:PodcastResponse">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<TextBlock Text="{Binding OwnerName}" />
|
||||
<TextBlock Text="{Binding State}" />
|
||||
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<WrapPanel>
|
||||
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
|
||||
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
|
||||
</WrapPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />
|
||||
<Button Content="Supprimer mon replay" Command="{Binding DeleteCommand}" IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal file
25
src/PostIt/PostIt/Views/PodcastsPage.axaml.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using Avalonia.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class PodcastsPage : ContentPage
|
||||
{
|
||||
public PodcastsPage() => InitializeComponent();
|
||||
|
||||
private void PodcastSelectionChanged(object? sender, SelectionChangedEventArgs e) => ConfirmDelete.IsChecked = false;
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||
{
|
||||
base.OnNavigatedTo(e);
|
||||
if (DataContext is PodcastsPageViewModel vm && !vm.IsBroadcasting)
|
||||
_ = vm.ActivateAsync();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
||||
{
|
||||
if (DataContext is PodcastsPageViewModel vm)
|
||||
_ = vm.DeactivateAsync();
|
||||
base.OnNavigatedFrom(e);
|
||||
}
|
||||
}
|
||||
192
src/Yavsc.Abstract/Administration/AdministrationDtos.cs
Normal file
192
src/Yavsc.Abstract/Administration/AdministrationDtos.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yavsc.Abstract.Administration;
|
||||
|
||||
/// <summary>Site administration counts and runtime information.</summary>
|
||||
public sealed record AdministrationSummary(int UserCount, int AdministratorCount, string Assembly, string Runtime);
|
||||
|
||||
/// <summary>A role with its current membership count.</summary>
|
||||
public sealed record AdministrationRole(string Id, string Name, int UserCount);
|
||||
|
||||
/// <summary>A user belonging to an administration role.</summary>
|
||||
public sealed record RoleMember(string Id, string UserName, string? Email);
|
||||
|
||||
/// <summary>Access token format understood by IdentityServer.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ClientAccessTokenType>))]
|
||||
public enum ClientAccessTokenType { Jwt, Reference }
|
||||
|
||||
/// <summary>Refresh token reuse policy.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ClientRefreshTokenUsage>))]
|
||||
public enum ClientRefreshTokenUsage { ReUse, OneTimeOnly }
|
||||
|
||||
/// <summary>Refresh token expiration policy.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ClientRefreshTokenExpiration>))]
|
||||
public enum ClientRefreshTokenExpiration { Sliding, Absolute }
|
||||
|
||||
/// <summary>Editable OAuth/OIDC scalar settings, independent of persistence entities.</summary>
|
||||
public sealed record ClientSettings
|
||||
{
|
||||
[Required, MaxLength(200)] public string ClientId { get; init; } = "";
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string? ClientName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? ClientUri { get; init; }
|
||||
public string? LogoUri { get; init; }
|
||||
[Required] public string ProtocolType { get; init; } = "oidc";
|
||||
public bool RequireClientSecret { get; init; } = true;
|
||||
public bool RequireConsent { get; init; }
|
||||
public bool AllowRememberConsent { get; init; } = true;
|
||||
public bool AlwaysIncludeUserClaimsInIdToken { get; init; }
|
||||
public bool RequirePkce { get; init; } = true;
|
||||
public bool AllowPlainTextPkce { get; init; }
|
||||
public bool RequireRequestObject { get; init; }
|
||||
public bool AllowAccessTokensViaBrowser { get; init; }
|
||||
public string? FrontChannelLogoutUri { get; init; }
|
||||
public bool FrontChannelLogoutSessionRequired { get; init; } = true;
|
||||
public string? BackChannelLogoutUri { get; init; }
|
||||
public bool BackChannelLogoutSessionRequired { get; init; } = true;
|
||||
public bool AllowOfflineAccess { get; init; }
|
||||
[Range(1, int.MaxValue)] public int IdentityTokenLifetime { get; init; } = 300;
|
||||
[Range(1, int.MaxValue)] public int AccessTokenLifetime { get; init; } = 3600;
|
||||
[Range(1, int.MaxValue)] public int AuthorizationCodeLifetime { get; init; } = 300;
|
||||
[Range(1, int.MaxValue)] public int AbsoluteRefreshTokenLifetime { get; init; } = 2592000;
|
||||
[Range(1, int.MaxValue)] public int SlidingRefreshTokenLifetime { get; init; } = 1296000;
|
||||
[Range(1, int.MaxValue)] public int? ConsentLifetime { get; init; }
|
||||
[EnumDataType(typeof(ClientAccessTokenType))] public ClientAccessTokenType AccessTokenType { get; init; }
|
||||
[EnumDataType(typeof(ClientRefreshTokenExpiration))]
|
||||
public ClientRefreshTokenExpiration RefreshTokenExpiration { get; init; } = ClientRefreshTokenExpiration.Absolute;
|
||||
[EnumDataType(typeof(ClientRefreshTokenUsage))]
|
||||
public ClientRefreshTokenUsage RefreshTokenUsage { get; init; } = ClientRefreshTokenUsage.OneTimeOnly;
|
||||
public bool UpdateAccessTokenClaimsOnRefresh { get; init; }
|
||||
public bool EnableLocalLogin { get; init; } = true;
|
||||
public bool IncludeJwtId { get; init; } = true;
|
||||
public bool AlwaysSendClientClaims { get; init; }
|
||||
public string? ClientClaimsPrefix { get; init; } = "client_";
|
||||
public string? PairWiseSubjectSalt { get; init; }
|
||||
[Range(1, int.MaxValue)] public int? UserSsoLifetime { get; init; }
|
||||
public string? UserCodeType { get; init; }
|
||||
[Range(1, int.MaxValue)] public int DeviceCodeLifetime { get; init; } = 300;
|
||||
public string? AllowedIdentityTokenSigningAlgorithms { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A client configuration collection supported by the web administration.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ClientCollection>))]
|
||||
public enum ClientCollection
|
||||
{
|
||||
RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes, CorsOrigins,
|
||||
IdentityProviders, Claims, Properties
|
||||
}
|
||||
|
||||
/// <summary>An editable collection entry; Key is used for claims and properties.</summary>
|
||||
public sealed record ClientEntry
|
||||
{
|
||||
public string? Key { get; init; }
|
||||
[Required, MaxLength(4096)] public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>A persisted collection row, without any credential material.</summary>
|
||||
public sealed record ClientEntryResponse(int Id, ClientCollection Collection, string? Key, string Value);
|
||||
|
||||
/// <summary>Secret metadata; neither plaintext nor a stored hash is exposed.</summary>
|
||||
public sealed record ClientSecretInfo(int Id, string? Description, DateTimeOffset Created, DateTimeOffset? Expiration);
|
||||
|
||||
/// <summary>A client and all its administrable collections.</summary>
|
||||
public sealed record AdministrationClient(int Id, ClientSettings Settings,
|
||||
IReadOnlyList<ClientEntryResponse> Entries, IReadOnlyList<ClientSecretInfo> Secrets);
|
||||
|
||||
/// <summary>Input for adding a shared secret or generating a replacement.</summary>
|
||||
public sealed record ClientSecretRequest
|
||||
{
|
||||
[MaxLength(4096)] public string? Value { get; init; }
|
||||
[MaxLength(2000)] public string? Description { get; init; }
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A newly generated client secret returned once, never available through GET.</summary>
|
||||
public sealed record NewClientSecret(int Id, string Value, DateTimeOffset? Expiration);
|
||||
|
||||
/// <summary>Editable API scope settings.</summary>
|
||||
public sealed record ApiScopeSettings
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name { get; init; } = "";
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public bool Required { get; init; }
|
||||
public bool Emphasize { get; init; }
|
||||
public bool ShowInDiscoveryDocument { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>A persisted API scope.</summary>
|
||||
public sealed record AdministrationScope(int Id, ApiScopeSettings Settings);
|
||||
|
||||
/// <summary>Mail delivery periodicity, compatible with the server calendar model.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<MailPeriodicity>))]
|
||||
public enum MailPeriodicity { OnDemand = -1, Daily, Weekly, Monthly, ThreeM, SixM, Yearly }
|
||||
|
||||
/// <summary>Editable mail template settings.</summary>
|
||||
public sealed record MailingTemplateSettings
|
||||
{
|
||||
[Required, StringLength(256, MinimumLength = 3)] public string Id { get; init; } = "";
|
||||
[Required, StringLength(256, MinimumLength = 3)] public string Topic { get; init; } = "";
|
||||
[Required, MaxLength(65536)] public string Body { get; init; } = "";
|
||||
[Required, EmailAddress] public string ReplyToAddress { get; init; } = "";
|
||||
[EnumDataType(typeof(MailPeriodicity))] public MailPeriodicity ToSend { get; init; } = MailPeriodicity.OnDemand;
|
||||
}
|
||||
|
||||
/// <summary>A safe account business-data transfer without credentials, confirmations or roles.</summary>
|
||||
public sealed record UserBusinessData
|
||||
{
|
||||
[Required] public string Id { get; init; } = "";
|
||||
[Required, MaxLength(256)] public string UserName { get; init; } = "";
|
||||
[Required, EmailAddress] public string Email { get; init; } = "";
|
||||
[MaxLength(512)] public string? FullName { get; init; }
|
||||
[MaxLength(512)] public string? Avatar { get; init; }
|
||||
[MaxLength(512)] public string? DedicatedGoogleCalendar { get; init; }
|
||||
public bool AllowMonthlyEmail { get; init; }
|
||||
[Range(0, long.MaxValue)] public long DiskQuota { get; init; } = 536870912;
|
||||
[Range(0, long.MaxValue)] public long MaxFileSize { get; init; } = 536870912;
|
||||
}
|
||||
|
||||
/// <summary>A blog business-data transfer retaining publication and access-control references.</summary>
|
||||
public sealed record BlogBusinessData
|
||||
{
|
||||
[Range(1, long.MaxValue)] public long Id { get; init; }
|
||||
[Required, MaxLength(1024)] public string Title { get; init; } = "";
|
||||
[MaxLength(56224)] public string? Article { get; init; }
|
||||
[MaxLength(1024)] public string? Photo { get; init; }
|
||||
[Required] public string AuthorId { get; init; } = "";
|
||||
[JsonRequired] public bool Published { get; init; }
|
||||
public DateTimeOffset DateCreated { get; init; }
|
||||
[JsonRequired] public long[] CircleIds { get; init; } = [];
|
||||
public string[] Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>An activity business-data transfer without navigation entities.</summary>
|
||||
public sealed record ActivityBusinessData
|
||||
{
|
||||
[Required, MaxLength(512)] public string Code { get; init; } = "";
|
||||
[Required, MaxLength(512)] public string Name { get; init; } = "";
|
||||
[MaxLength(512)] public string? ParentCode { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Photo { get; init; }
|
||||
public bool Moderated { get; init; }
|
||||
public string? ModeratorGroupName { get; init; }
|
||||
[Range(0, 100)] public int Rate { get; init; }
|
||||
public string? SettingsClassName { get; init; }
|
||||
public bool Hidden { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A versioned business-data transfer. No authentication secrets are part of this schema.</summary>
|
||||
public sealed record AdministrationTransfer
|
||||
{
|
||||
[JsonRequired, Range(1, 1)] public int Version { get; init; } = 1;
|
||||
[JsonRequired, Required] public UserBusinessData[] Users { get; init; } = [];
|
||||
[JsonRequired, Required] public BlogBusinessData[] Blogs { get; init; } = [];
|
||||
[JsonRequired, Required] public ActivityBusinessData[] Activities { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Counts of rows successfully imported in a single transaction.</summary>
|
||||
public sealed record ImportResult(int Users, int Blogs, int Activities);
|
||||
|
|
@ -20,6 +20,8 @@ namespace Yavsc.Billing
|
|||
|
||||
DateTime? ValidationDate { get; }
|
||||
|
||||
PaymentSummary? PaymentSummary { get; }
|
||||
|
||||
bool GetIsAcquitted ();
|
||||
|
||||
string GetFileBaseName (IBillingService service);
|
||||
|
|
|
|||
38
src/Yavsc.Abstract/Billing/InvoiceDtos.cs
Normal file
38
src/Yavsc.Abstract/Billing/InvoiceDtos.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Yavsc.Billing;
|
||||
|
||||
/// <summary>A frozen invoice line copied from the signed estimate.</summary>
|
||||
public sealed record InvoiceLineResponse(string Name, string Description, int Count, decimal UnitaryCost);
|
||||
|
||||
/// <summary>An invoice and its cumulative settlement balance.</summary>
|
||||
public sealed record InvoiceResponse(
|
||||
long CommandId, long EstimateId, string Reference, string ClientId, string PerformerId,
|
||||
DateTimeOffset CreatedAtUtc, string Currency, decimal AmountDue, decimal AmountPaid,
|
||||
decimal AmountRemaining, string PaymentStatus, bool IsAcquitted,
|
||||
IReadOnlyList<InvoiceLineResponse> Lines, IReadOnlyList<InvoicePaymentResponse> Payments);
|
||||
|
||||
/// <summary>An immutable recorded receipt, not an instruction to debit the client.</summary>
|
||||
public sealed record InvoicePaymentResponse(
|
||||
long Id, string Source, string Reference, decimal Amount, string Currency,
|
||||
DateTimeOffset ReceivedAtUtc, DateTimeOffset RecordedAtUtc, string RecordedBy);
|
||||
|
||||
/// <summary>A receipt attested by the invoice's performer.</summary>
|
||||
public sealed record RecordInvoicePaymentRequest
|
||||
{
|
||||
[Required, MaxLength(128)]
|
||||
public required string Reference { get; init; }
|
||||
public required decimal Amount { get; init; }
|
||||
[Required, StringLength(3, MinimumLength = 3)]
|
||||
public required string Currency { get; init; }
|
||||
public required DateTimeOffset ReceivedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A PayPal transaction identifier to verify on the server; no client amount is trusted.</summary>
|
||||
public sealed record VerifyInvoicePayPalRequest
|
||||
{
|
||||
[Required, MaxLength(128)]
|
||||
public required string TransactionId { get; init; }
|
||||
}
|
||||
8
src/Yavsc.Abstract/Billing/PaymentStatus.cs
Normal file
8
src/Yavsc.Abstract/Billing/PaymentStatus.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Yavsc.Billing;
|
||||
|
||||
public enum PaymentStatus
|
||||
{
|
||||
Unpaid,
|
||||
PartiallyPaid,
|
||||
Paid
|
||||
}
|
||||
21
src/Yavsc.Abstract/Billing/PaymentSummary.cs
Normal file
21
src/Yavsc.Abstract/Billing/PaymentSummary.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace Yavsc.Billing;
|
||||
|
||||
public sealed class PaymentSummary
|
||||
{
|
||||
public PaymentSummary(decimal amountDue, decimal amountPaid)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(amountDue);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(amountPaid);
|
||||
|
||||
AmountDue = amountDue;
|
||||
AmountPaid = amountPaid;
|
||||
}
|
||||
|
||||
public decimal AmountDue { get; }
|
||||
public decimal AmountPaid { get; }
|
||||
public decimal AmountRemaining => Math.Max(0m, AmountDue - AmountPaid);
|
||||
public bool IsAcquitted => AmountPaid >= AmountDue;
|
||||
public PaymentStatus Status => IsAcquitted
|
||||
? PaymentStatus.Paid
|
||||
: AmountPaid > 0m ? PaymentStatus.PartiallyPaid : PaymentStatus.Unpaid;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ namespace Yavsc
|
|||
public const string CirclePath = "circle";
|
||||
public const string CommentsPath = "blogcomments";
|
||||
public const string ChatHubPath = "chathub";
|
||||
public const string Administration = "administration";
|
||||
|
||||
public static readonly Scope[] SiteScopes = {
|
||||
new Scope { Id = "profile", Description = "Your profile informations" },
|
||||
|
|
@ -35,6 +36,8 @@ namespace Yavsc
|
|||
public const string ResourceUsageAdminRoute = "admin";
|
||||
public const string NotificationsPath = "notifications";
|
||||
public const string NotificationsCurrentUserRoute = "me";
|
||||
public const string NotificationsDismissPath = "dismiss";
|
||||
public const string NotificationsDismissClickRoute = "click";
|
||||
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||
|
|
@ -50,7 +53,7 @@ namespace Yavsc
|
|||
public const string DefaultFactor = "Default";
|
||||
public const string MobileAppFactor = "Mobile Application";
|
||||
public const string SMSFactor = "SMS";
|
||||
public const string AdminGroupName = "Administrator";
|
||||
public const string AdminName = "Administrator";
|
||||
public const string PerformerGroupName = "Performer";
|
||||
public const string StarGroupName = "Star";
|
||||
public const string StarHunterGroupName = "StarHunter";
|
||||
|
|
|
|||
11
src/Yavsc.Abstract/Messaging/DismissedNotification.cs
Normal file
11
src/Yavsc.Abstract/Messaging/DismissedNotification.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace Yavsc.Abstract.Models.Messaging
|
||||
{
|
||||
/// <summary>
|
||||
/// Wire format of a notification dismissed by the current user.
|
||||
/// The user is always implied by the authenticated caller.
|
||||
/// </summary>
|
||||
public sealed class DismissedNotification
|
||||
{
|
||||
public long NotificationId { get; set; }
|
||||
}
|
||||
}
|
||||
28
src/Yavsc.Abstract/Streaming/Podcast.cs
Normal file
28
src/Yavsc.Abstract/Streaming/Podcast.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yavsc.Abstract.Streaming;
|
||||
|
||||
/// <summary>The lifecycle of a retained live broadcast.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PodcastState>))]
|
||||
public enum PodcastState { Live, Completed, Interrupted }
|
||||
|
||||
/// <summary>Creates an authenticated live broadcast with a retained replay.</summary>
|
||||
public sealed record CreatePodcastRequest
|
||||
{
|
||||
[Required, StringLength(160, MinimumLength = 1)]
|
||||
public required string Title { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A broadcast available to authenticated listeners.</summary>
|
||||
public sealed record PodcastResponse(
|
||||
Guid Id, string Title, string OwnerId, string OwnerName,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, PodcastState State,
|
||||
int SegmentCount, long Bytes, double DurationSeconds);
|
||||
|
||||
/// <summary>An independently decodable MPEG-TS HLS segment.</summary>
|
||||
public sealed record PodcastSegmentResponse(int Sequence, double DurationSeconds, int Bytes);
|
||||
|
||||
/// <summary>The ordered, committed segments of a live broadcast or replay.</summary>
|
||||
public sealed record PodcastManifestResponse(
|
||||
PodcastResponse Podcast, IReadOnlyList<PodcastSegmentResponse> Segments);
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<Library>true</Library>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||
<Version>1.1.0-estimate.1</Version>
|
||||
<AssemblyVersion>1.0.8.0</AssemblyVersion>
|
||||
<FileVersion>1.0.8.0</FileVersion>
|
||||
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
|
||||
<Version>1.0.8-458</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue