Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ed75f06fb2 |
|||
|
dfef7652a4 |
|||
|
d476c08046 |
|||
|
d192705f29 |
|||
|
e7e6b375f5 |
|||
|
0893805ac4 |
|||
|
0d79ba89a6 |
|||
|
9c0c60ebe7 |
|||
|
0e9f61bd24 |
64 changed files with 1631 additions and 113 deletions
93
CHANGELOG.md
93
CHANGELOG.md
|
|
@ -1,24 +1,83 @@
|
|||
|
||||
# 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 partagee avec le desktop : surface LibVLC native Android et moteur
|
||||
embarque, transport authentifie commun et controles uniques. Liberation
|
||||
du lecteur en arriere-plan ; reouverture du flux au retour dans l'application.
|
||||
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 cles. Interface, envoi authentifie, retries et finalisation
|
||||
sur images clés. Interface, envoi authentifie, retries et finalisation
|
||||
du replay partages avec le desktop. Service de premier plan avec
|
||||
notification d'arret et maintien de la capture en arriere-plan.
|
||||
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
|
||||
|
||||
|
|
@ -27,38 +86,38 @@
|
|||
* [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 verifiees cote serveur ;
|
||||
references uniques, cumul atomique et protection contre les ecritures
|
||||
concurrentes. Routes documentees, client HTTP et migration associes.
|
||||
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] Modele de solde des commandes facturables :
|
||||
montants dus et payes persistants, reste a payer et etats non paye,
|
||||
* [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 authentifiee dans PostIt.
|
||||
envoi ordonne et idempotent, catalogue et lecture authentifiée dans PostIt.
|
||||
L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur
|
||||
disque, quotas dedies et conservation des segments recus apres interruption.
|
||||
disque, quotas dédiés et conservation des segments reçus apres interruption.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
* [Facturation] Les commandes facturees et leur devis ne peuvent plus etre
|
||||
* [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 etablie depuis un devis signe.
|
||||
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 Demarrer,
|
||||
protocole `postit://` pour la connexion et desinstallation via Windows.
|
||||
Instructions de generation ajoutees dans `INSTALL.md`.
|
||||
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
|
||||
|
|
|
|||
22
INSTALL.md
22
INSTALL.md
|
|
@ -166,6 +166,15 @@ 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
|
||||
|
|
@ -176,12 +185,13 @@ 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 limitation:** bundled `VideoLAN.LibVLC.Android` 3.6.5 emits
|
||||
`XA0141`: its native libraries are not aligned for 16 KiB memory pages.
|
||||
Playback on 16 KiB-page devices and store distribution requiring this alignment
|
||||
are not validated/supported by this package choice. A compatible native LibVLC
|
||||
release is required before claiming that support; a successful APK build or
|
||||
a test on a 4 KiB-page emulator does not establish it.
|
||||
**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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,59 @@ 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
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<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.6.5" />
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
</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.*).
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private MediaRecorder? _recorder;
|
||||
private AndroidCameraFrameRotator? _rotator;
|
||||
private Surface? _surface;
|
||||
private ParcelFileDescriptor? _readPipe;
|
||||
private ParcelFileDescriptor? _writePipe;
|
||||
|
|
@ -36,6 +37,8 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
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;
|
||||
|
||||
|
|
@ -103,6 +106,30 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
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();
|
||||
|
|
@ -111,7 +138,8 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
|
||||
_recorder.SetVideoSource(VideoSource.Surface);
|
||||
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
|
||||
_recorder.SetVideoSize(size.Width, size.Height);
|
||||
_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);
|
||||
|
|
@ -131,20 +159,23 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
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> { _surface }, new SessionCallback(this, configured), _handler);
|
||||
_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(_surface);
|
||||
request.AddTarget(cameraSurface);
|
||||
using var built = request.Build();
|
||||
_session.SetRepeatingRequest(built, null, _handler);
|
||||
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
|
||||
var stream = new FileStream(handle, FileAccess.Read);
|
||||
_pump = Task.Run(async () =>
|
||||
|
|
@ -169,6 +200,8 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
});
|
||||
await Task.Run(_recorder.Start, ct);
|
||||
_started = true;
|
||||
await _rotator.StartAsync();
|
||||
_session.SetRepeatingRequest(built, null, _handler);
|
||||
_writePipe.Close();
|
||||
_writePipe.Dispose();
|
||||
_writePipe = null;
|
||||
|
|
@ -261,7 +294,11 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
|
||||
private void StopNative()
|
||||
{
|
||||
try { if (_started) _recorder?.Stop(); }
|
||||
try
|
||||
{
|
||||
// Keep frames flowing until MediaRecorder has drained its encoder.
|
||||
if (_started) _recorder?.Stop();
|
||||
}
|
||||
catch (Java.Lang.RuntimeException ex)
|
||||
{
|
||||
_failure = ex;
|
||||
|
|
@ -276,6 +313,8 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
|
|||
_camera?.Close();
|
||||
_camera?.Dispose();
|
||||
_camera = null;
|
||||
_rotator?.Dispose();
|
||||
_rotator = null;
|
||||
if (_recorder is not null)
|
||||
{
|
||||
_recorder.Error -= OnRecorderError;
|
||||
|
|
|
|||
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.");
|
||||
}
|
||||
}
|
||||
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.**
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
#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;
|
||||
|
|
@ -34,7 +36,7 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
|
|||
try
|
||||
{
|
||||
await RunAsync(result);
|
||||
File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}");
|
||||
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)
|
||||
{
|
||||
|
|
@ -68,6 +70,11 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
|
|||
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)
|
||||
{
|
||||
|
|
@ -81,7 +88,25 @@ public sealed class AndroidPodcastCaptureTestActivity : Activity
|
|||
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}");
|
||||
}
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -57,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using PostIt.ViewModels.Chat;
|
|||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public class HomePageViewModel : ViewModelBase
|
||||
|
|
@ -86,7 +87,7 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
public IAsyncRelayCommand OpenChat { get; }
|
||||
public IAsyncRelayCommand OpenPodcasts { get; }
|
||||
public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
|
||||
public bool SupportsPodcasts => Platform.IsPodcastSupported;
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenProfile { get; }
|
||||
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
if (profile is null)
|
||||
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
|
||||
if (version == _accessVersion)
|
||||
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminGroupName, StringComparer.Ordinal);
|
||||
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminName, StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
@ -52,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";
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public sealed class NotificationsApiController : Controller
|
|||
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
|
||||
{
|
||||
var currentUserId = User.GetUserId();
|
||||
var isAdmin = User.IsInRole(Constants.AdminGroupName);
|
||||
var isAdmin = User.IsInRole(Constants.AdminName);
|
||||
|
||||
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
|
||||
? new HashSet<long>()
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller
|
|||
[HttpGet(Constants.ResourceUsageAdminRoute)]
|
||||
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
|
||||
{
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ using Yavsc.Api.Services;
|
|||
namespace Yavsc.ApiControllers.Administration;
|
||||
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(GroupName = "administration")]
|
||||
[ApiExplorerSettings(GroupName = Constants.Administration)]
|
||||
[Authorize("AdministratorOnly")]
|
||||
[Route(Constants.APIPrefix + "/administration")]
|
||||
[Produces("application/json")]
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ namespace Yavsc.ApiControllers
|
|||
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
|
||||
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice)
|
||||
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
|
||||
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminName))
|
||||
return Forbid();
|
||||
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
bool UserIsAdminOrThis(string uid)
|
||||
{
|
||||
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||
if (User.IsInRole(Constants.AdminName)) return true;
|
||||
return uid == User.GetUserId();
|
||||
}
|
||||
bool UserIsAdminOrInThese(string oid, string uid)
|
||||
{
|
||||
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||
if (User.IsInRole(Constants.AdminName)) return true;
|
||||
var cuid = User.GetUserId();
|
||||
return cuid == uid || cuid == oid;
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ namespace Yavsc.Controllers
|
|||
return BadRequest();
|
||||
}
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
if (uid != estimate.OwnerId)
|
||||
{
|
||||
|
|
@ -115,7 +115,7 @@ namespace Yavsc.Controllers
|
|||
var uid = User.GetUserId();
|
||||
if (estimate.OwnerId == null) estimate.OwnerId = uid;
|
||||
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
if (uid != estimate.OwnerId)
|
||||
{
|
||||
|
|
@ -185,7 +185,7 @@ namespace Yavsc.Controllers
|
|||
return NotFound();
|
||||
}
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
if (uid != estimate.OwnerId)
|
||||
{
|
||||
|
|
@ -285,7 +285,7 @@ namespace Yavsc.Controllers
|
|||
if (estimate == null) return NotFound();
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||
if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId)
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
if (body == null || body.FileId <= 0)
|
||||
|
|
@ -319,7 +319,7 @@ namespace Yavsc.Controllers
|
|||
if (estimate == null) return NotFound();
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||
if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId)
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var link = _context.EstimateAttachedFiles
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (estimateTemplate.OwnerId!=uid)
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
_context.Entry(estimateTemplate).State = EntityState.Modified;
|
||||
|
|
@ -133,7 +133,7 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (estimateTemplate.OwnerId!=uid)
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
_context.EstimateTemplates.Remove(estimateTemplate);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ namespace Yavsc.ApiControllers
|
|||
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName)
|
||||
if (!User.IsInRole(Constants.AdminName)
|
||||
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
||||
return Forbid();
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ namespace Yavsc.ApiControllers
|
|||
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName)
|
||||
if (!User.IsInRole(Constants.AdminName)
|
||||
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
||||
return Forbid();
|
||||
|
||||
|
|
@ -403,7 +403,7 @@ namespace Yavsc.ApiControllers
|
|||
if (isProvider) return (query, ActorRole.Provider, null);
|
||||
if (isClient) return (query, ActorRole.Client, null);
|
||||
|
||||
if (User.IsInRole(Constants.AdminGroupName))
|
||||
if (User.IsInRole(Constants.AdminName))
|
||||
return (query, ActorRole.Admin, null);
|
||||
|
||||
return (null, ActorRole.None, Forbid());
|
||||
|
|
@ -453,7 +453,7 @@ namespace Yavsc.ApiControllers
|
|||
if (error is not null) return error;
|
||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
if (body == null || body.FileId <= 0)
|
||||
|
|
@ -463,7 +463,7 @@ namespace Yavsc.ApiControllers
|
|||
var file = await dbContext.UploadedFiles
|
||||
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
|
||||
if (file == null) return BadRequest(new { error = "file not found" });
|
||||
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var existing = await dbContext.QueryAttachedFiles
|
||||
|
|
@ -490,7 +490,7 @@ namespace Yavsc.ApiControllers
|
|||
if (error is not null) return error;
|
||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var link = await dbContext.QueryAttachedFiles
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ public class HairCutQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ public class HairCutQueryApiController : Controller
|
|||
}
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ public class HairCutQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -234,4 +234,4 @@ public class HairCutQueryApiController : Controller
|
|||
Details = prestation.GetDisplayDetails(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ public class HairMultiCutQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -152,7 +152,7 @@ public class HairMultiCutQueryApiController : Controller
|
|||
}
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ public class HairMultiCutQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -285,4 +285,4 @@ public class HairMultiCutQueryApiController : Controller
|
|||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public sealed class InvoicesController(IInvoicingService invoices, ILogger<Invoi
|
|||
public Task<ActionResult<InvoiceResponse>> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct)
|
||||
=> ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct));
|
||||
|
||||
private bool IsAdmin => User.IsInRole(Constants.AdminGroupName);
|
||||
private bool IsAdmin => User.IsInRole(Constants.AdminName);
|
||||
|
||||
private async Task<ActionResult<InvoiceResponse>> ExecuteAsync(Func<Task<InvoiceResponse>> action, bool created = false)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public class RdvQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ public class RdvQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -222,7 +222,7 @@ public class RdvQueryApiController : Controller
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
if (query.ClientId != uid && !User.IsInRole(Constants.AdminName))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (blogpost.AuthorId!=uid)
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
return BadRequest();
|
||||
|
||||
_context.SaveChanges(User.GetUserId());
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (uid != blackListed.OwnerId)
|
||||
if (!User.IsInRole(Constants.AdminGroupName))
|
||||
if (!User.IsInRole(Constants.AdminName))
|
||||
if (!User.IsInRole(Constants.FrontOfficeGroupName))
|
||||
return false;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId
|
||||
&& ! User.IsInMsRole(Constants.AdminGroupName))
|
||||
&& ! User.IsInMsRole(Constants.AdminName))
|
||||
|
||||
{
|
||||
ModelState.AddModelError("UserId","get refused");
|
||||
|
|
@ -72,7 +72,7 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
|
||||
|
||||
if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName))
|
||||
{
|
||||
ModelState.AddModelError("ChannelName", "access put refused");
|
||||
return BadRequest(ModelState);
|
||||
|
|
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
|
||||
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName)))
|
||||
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName)))
|
||||
{
|
||||
ModelState.AddModelError("ChannelName", "access post refused");
|
||||
return BadRequest(ModelState);
|
||||
|
|
@ -154,7 +154,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
|
||||
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminGroupName)))
|
||||
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminName)))
|
||||
{
|
||||
ModelState.AddModelError("UserId", "access drop refused");
|
||||
return BadRequest(ModelState);
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
if (User.GetUserId() != chatRoom.OwnerId )
|
||||
{
|
||||
if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (!User.IsInMsRole(Constants.AdminName))
|
||||
return BadRequest(new {error = "OwnerId"});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using Yavsc.Models;
|
|||
using Yavsc;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Settings;
|
||||
using Yavsc.Server.Hubs;
|
||||
using Yavsc.Api.Services;
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ internal class Program
|
|||
{
|
||||
policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
|
||||
.RequireClaim(Constants.RoleClaimType, Constants.AdminName);
|
||||
});
|
||||
})
|
||||
.AddYavscCors(builder.Configuration)
|
||||
|
|
@ -71,6 +72,7 @@ internal class Program
|
|||
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
|
||||
// the document-generation flow and BillingController.
|
||||
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
|
||||
services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
services.AddPodcasts(builder.Configuration);
|
||||
services.AddInvoicing(builder.Configuration);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public sealed partial class AdministrationService(
|
|||
|
||||
public async Task<AdministrationSummary> GetSummaryAsync(CancellationToken ct)
|
||||
=> new(await db.Users.CountAsync(ct),
|
||||
(await users.GetUsersInRoleAsync(Constants.AdminGroupName)).Count,
|
||||
(await users.GetUsersInRoleAsync(Constants.AdminName)).Count,
|
||||
typeof(AdministrationService).Assembly.GetName().ToString(), System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationRole>> GetRolesAsync(CancellationToken ct)
|
||||
|
|
@ -46,7 +46,7 @@ public sealed partial class AdministrationService(
|
|||
|
||||
public async Task EnsureRolesAsync(CancellationToken ct)
|
||||
{
|
||||
foreach (var name in new[] { Constants.AdminGroupName, Constants.StarGroupName, Constants.PerformerGroupName,
|
||||
foreach (var name in new[] { Constants.AdminName, Constants.StarGroupName, Constants.PerformerGroupName,
|
||||
Constants.FrontOfficeGroupName, Constants.StarHunterGroupName, Constants.BlogModeratorGroupName })
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
|
@ -73,7 +73,7 @@ public sealed partial class AdministrationService(
|
|||
CheckIdentity(await users.AddToRoleAsync(user, role.Name!));
|
||||
else if (!member && alreadyMember)
|
||||
{
|
||||
if (role.Name == Constants.AdminGroupName &&
|
||||
if (role.Name == Constants.AdminName &&
|
||||
(await users.GetUsersInRoleAsync(role.Name)).Count <= 1)
|
||||
throw Conflict("Impossible de retirer le dernier administrateur.");
|
||||
CheckIdentity(await users.RemoveFromRoleAsync(user, role.Name!));
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ public static class AdministrationServiceCollectionExtensions
|
|||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<IAdministrationService, AdministrationService>();
|
||||
services.AddScoped<AdministrationExceptionFilter>();
|
||||
services.AddOpenApi("administration", options =>
|
||||
options.ShouldInclude = description => description.GroupName == "administration");
|
||||
services.AddOpenApi(Constants.Administration, options =>
|
||||
options.ShouldInclude = description => description.GroupName == Constants.Administration);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
// autorisés comme pour le reste du workflow.
|
||||
bool CanReadFile(UploadedFile file, string uid)
|
||||
{
|
||||
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||
if (User.IsInRole(Constants.AdminName)) return true;
|
||||
if (file.OwnerId == uid) return true;
|
||||
|
||||
if (dbContext.EstimateAttachedFiles.Any(a =>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
// ensure all roles existence
|
||||
foreach (string roleName in new string[] {
|
||||
Constants.AdminGroupName,
|
||||
Constants.AdminName,
|
||||
Constants.StarGroupName,
|
||||
Constants.PerformerGroupName,
|
||||
Constants.FrontOfficeGroupName,
|
||||
|
|
@ -86,13 +86,12 @@ namespace Yavsc.Controllers
|
|||
public async Task<IActionResult> Take()
|
||||
{
|
||||
// If some amdin already exists, make this method disapear
|
||||
var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminGroupName);
|
||||
var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminName);
|
||||
if (admins != null && admins.Count > 0)
|
||||
{
|
||||
// All is ok, nothing to do here.
|
||||
if (User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (User.IsInMsRole(Constants.AdminName))
|
||||
{
|
||||
|
||||
return Ok(new { message = "you already got it." });
|
||||
}
|
||||
return NotFound();
|
||||
|
|
@ -116,7 +115,7 @@ namespace Yavsc.Controllers
|
|||
return new BadRequestObjectResult(ModelState);
|
||||
}
|
||||
|
||||
var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminGroupName);
|
||||
var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminName);
|
||||
if (!addToRoleResult.Succeeded)
|
||||
{
|
||||
AddErrors(addToRoleResult);
|
||||
|
|
@ -130,11 +129,11 @@ namespace Yavsc.Controllers
|
|||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var adminCount = await _userManager.GetUsersInRoleAsync(
|
||||
Constants.AdminGroupName);
|
||||
Constants.AdminName);
|
||||
var userCount = await _dbContext.Users.CountAsync();
|
||||
var youAreAdmin = await _userManager.IsInRoleAsync(
|
||||
await _userManager.FindByIdAsync(User.GetUserId()),
|
||||
Constants.AdminGroupName);
|
||||
Constants.AdminName);
|
||||
|
||||
var roles = await _roleManager.Roles.Select(x => new RoleInfo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
private async Task SetupView(Announce announce)
|
||||
{
|
||||
ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminGroupName);
|
||||
ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminName);
|
||||
ViewBag.IsPerformer = User.IsInMsRole(Constants.PerformerGroupName);
|
||||
ViewBag.AllowEdit = announce==null || announce.Id<=0 || !_authorizationService.AuthorizeAsync(User,announce,new EditPermission()).IsFaulted;
|
||||
List<SelectListItem> dl = new List<SelectListItem>();
|
||||
|
|
@ -80,7 +80,7 @@ namespace Yavsc.Controllers
|
|||
if (ModelState.IsValid)
|
||||
{
|
||||
// Only allow admin to create corporate annonces
|
||||
if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminName))
|
||||
{
|
||||
ModelState.AddModelError("For", _localizer["YourNotAdmin"]);
|
||||
return View(announce);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Yavsc.Controllers
|
||||
{
|
||||
|
|
@ -10,6 +12,7 @@ namespace Yavsc.Controllers
|
|||
using Microsoft.Extensions.Localization;
|
||||
using Models;
|
||||
using ViewModels.FrontOffice;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
|
||||
|
|
@ -20,18 +23,24 @@ namespace Yavsc.Controllers
|
|||
readonly ILogger _logger;
|
||||
readonly IStringLocalizer _SR;
|
||||
private readonly IBillingService _billing;
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider;
|
||||
private readonly ILiveProcessor _liveProcessor;
|
||||
|
||||
public FrontOfficeController(ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IBillingService billing,
|
||||
ILoggerFactory loggerFactory,
|
||||
IStringLocalizer<FrontOfficeController> SR)
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
ILiveProcessor liveProcessor,
|
||||
IStringLocalizer<FrontOfficeController> SR)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
||||
_SR = SR;
|
||||
_billing = billing;
|
||||
_dataProtectionProvider = dataProtectionProvider;
|
||||
_liveProcessor = liveProcessor;
|
||||
}
|
||||
|
||||
public ActionResult Index()
|
||||
|
|
@ -77,5 +86,145 @@ namespace Yavsc.Controllers
|
|||
return View(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> PerformerPodcasts(string id, string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return NotFound();
|
||||
|
||||
if (!TryValidateToken(id, token, out var expireAt))
|
||||
return BadRequest("Ce lien public de podcast a expire ou est invalide.");
|
||||
|
||||
var performer = await _context.Performers
|
||||
.Include(p => p.Performer)
|
||||
.FirstOrDefaultAsync(p => p.PerformerId == id);
|
||||
|
||||
if (performer == null) return NotFound();
|
||||
|
||||
var podcasts = (await _liveProcessor.ListAsync(HttpContext.RequestAborted))
|
||||
.Where(p => string.Equals(p.OwnerId, id, StringComparison.Ordinal))
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ToArray();
|
||||
|
||||
ViewBag.PerformerName = performer.Performer?.UserName ?? performer.PerformerId;
|
||||
ViewBag.PerformerId = id;
|
||||
ViewBag.PermalinkExpiry = expireAt;
|
||||
ViewBag.Permalink = Url.Action(nameof(PerformerPodcasts), "FrontOffice", new { id, token = CreateToken(id) }, Request.Scheme, Request.Host.Value);
|
||||
|
||||
return View(podcasts);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> PerformerActivity(string id, string activityCode, string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(activityCode)) return NotFound();
|
||||
|
||||
if (!TryValidateToken(id, activityCode, token, out var expireAt))
|
||||
return BadRequest("Ce lien public pour cette activité a expire ou est invalide.");
|
||||
|
||||
var performer = await _context.Performers
|
||||
.Include(p => p.Performer)
|
||||
.FirstOrDefaultAsync(p => p.PerformerId == id);
|
||||
|
||||
var activity = await _context.Activities
|
||||
.FirstOrDefaultAsync(a => a.Code == activityCode);
|
||||
|
||||
if (performer == null || activity == null) return NotFound();
|
||||
|
||||
var profile = (await _context.ListPerformersAsync(_billing, activityCode))
|
||||
.Where(p => string.Equals(p.PerformerId, id, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
ViewBag.Activity = activity;
|
||||
ViewBag.PerformerName = performer.Performer?.UserName ?? performer.PerformerId;
|
||||
ViewBag.PerformerId = id;
|
||||
ViewBag.PermalinkExpiry = expireAt;
|
||||
ViewBag.Permalink = Url.Action(nameof(PerformerActivity), "FrontOffice", new { id, activityCode, token = CreateActivityToken(id, activityCode) }, Request.Scheme, Request.Host.Value);
|
||||
|
||||
return View("Profiles", profile);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public IActionResult GeneratePerformerPodcastPermalink(string id)
|
||||
{
|
||||
var currentUserId = User.GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(currentUserId) || !string.Equals(currentUserId, id, StringComparison.Ordinal))
|
||||
return Forbid();
|
||||
|
||||
var token = CreateToken(id);
|
||||
return RedirectToAction(nameof(PerformerPodcasts), new { id, token });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public IActionResult GeneratePerformerActivityPermalink(string id, string activityCode)
|
||||
{
|
||||
var currentUserId = User.GetUserId();
|
||||
if (string.IsNullOrWhiteSpace(currentUserId) || !string.Equals(currentUserId, id, StringComparison.Ordinal))
|
||||
return Forbid();
|
||||
|
||||
var token = CreateActivityToken(id, activityCode);
|
||||
return RedirectToAction(nameof(PerformerActivity), new { id, activityCode, token });
|
||||
}
|
||||
|
||||
private string CreateToken(string performerId)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
|
||||
var protector = _dataProtectionProvider.CreateProtector("Yavsc.Org.PerformerPodcastLink");
|
||||
var payload = new
|
||||
{
|
||||
performerId,
|
||||
exp = expiresAt.ToUnixTimeSeconds(),
|
||||
};
|
||||
return protector.Protect(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private string CreateActivityToken(string performerId, string activityCode)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
|
||||
var protector = _dataProtectionProvider.CreateProtector("Yavsc.Org.PerformerActivityLink");
|
||||
var payload = new
|
||||
{
|
||||
performerId,
|
||||
activityCode,
|
||||
exp = expiresAt.ToUnixTimeSeconds(),
|
||||
};
|
||||
return protector.Protect(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private bool TryValidateToken(string performerId, string? token, out DateTimeOffset validUntil)
|
||||
=> TryValidateTokenInternal(performerId, null, token, out validUntil, "Yavsc.Org.PerformerPodcastLink");
|
||||
|
||||
private bool TryValidateToken(string performerId, string activityCode, string? token, out DateTimeOffset validUntil)
|
||||
=> TryValidateTokenInternal(performerId, activityCode, token, out validUntil, "Yavsc.Org.PerformerActivityLink");
|
||||
|
||||
private bool TryValidateTokenInternal(string performerId, string? activityCode, string? token, out DateTimeOffset validUntil, string protectorName)
|
||||
{
|
||||
validUntil = DateTimeOffset.MinValue;
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var protector = _dataProtectionProvider.CreateProtector(protectorName);
|
||||
var payload = JsonDocument.Parse(protector.Unprotect(token));
|
||||
var ownerId = payload.RootElement.GetProperty("performerId").GetString();
|
||||
var expiry = payload.RootElement.GetProperty("exp").GetInt64();
|
||||
|
||||
if (!string.Equals(ownerId, performerId, StringComparison.Ordinal)) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(activityCode))
|
||||
{
|
||||
if (!payload.RootElement.TryGetProperty("activityCode", out var activityPayload)) return false;
|
||||
if (!string.Equals(activityPayload.GetString(), activityCode, StringComparison.Ordinal)) return false;
|
||||
}
|
||||
|
||||
validUntil = DateTimeOffset.FromUnixTimeSeconds(expiry);
|
||||
return validUntil > DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,14 @@ namespace Yavsc.Controllers
|
|||
if (clicked == null) clicked = new long[0];
|
||||
}
|
||||
else clicked = _dbContext.DismissClicked.Where(d => d.UserId == uid).Select(d => d.NotificationId).ToArray();
|
||||
var isAdmin = User.IsInRole(Constants.AdminName);
|
||||
var notes = _dbContext.Notification.Where(
|
||||
n => !clicked.Contains(n.Id)
|
||||
);
|
||||
).Where(n =>
|
||||
string.IsNullOrWhiteSpace(n.Target)
|
||||
|| (n.Target != null && n.Target.StartsWith("pub/"))
|
||||
|| (!string.IsNullOrWhiteSpace(uid) && n.Target == $"user/{uid}")
|
||||
|| (isAdmin && n.Target == Constants.Administration));
|
||||
if (notes.Any()) this.Notify(notes);
|
||||
|
||||
var toShow = _dbContext.Activities
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ namespace Yavsc.Controllers
|
|||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
|
||||
return new ChallengeResult();
|
||||
|
||||
_context.Instrumentation.Add(model);
|
||||
|
|
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
return NotFound();
|
||||
}
|
||||
if (id != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (id != uid) if (!User.IsInMsRole(Constants.AdminName))
|
||||
return new ChallengeResult();
|
||||
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
|
||||
if (musicianSettings == null)
|
||||
|
|
@ -97,7 +97,7 @@ namespace Yavsc.Controllers
|
|||
public async Task<IActionResult> Edit(Instrumentation musicianSettings)
|
||||
{
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
|
||||
return new ChallengeResult();
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
|
|
@ -123,7 +123,7 @@ namespace Yavsc.Controllers
|
|||
return NotFound();
|
||||
}
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
|
||||
return new ChallengeResult();
|
||||
return View(musicianSettings);
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ namespace Yavsc.Controllers
|
|||
Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id);
|
||||
|
||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName))
|
||||
return new ChallengeResult();
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -263,14 +263,14 @@ public static class HostingExtensions
|
|||
policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireClaim(Constants.RoleClaimType,
|
||||
new string[] { Constants.PerformerGroupName, Constants.AdminGroupName })
|
||||
new string[] { Constants.PerformerGroupName, Constants.AdminName })
|
||||
;
|
||||
});
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
{
|
||||
_ = policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
|
||||
.RequireClaim(Constants.RoleClaimType, Constants.AdminName);
|
||||
});
|
||||
|
||||
options.AddPolicy("FrontOffice", policy => policy.RequireRole(Constants.FrontOfficeGroupName));
|
||||
|
|
|
|||
87
src/Yavsc.Org/Views/FrontOffice/PerformerPodcasts.cshtml
Normal file
87
src/Yavsc.Org/Views/FrontOffice/PerformerPodcasts.cshtml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
@model IReadOnlyList<Yavsc.Abstract.Streaming.PodcastResponse>
|
||||
@{
|
||||
ViewBag.Title = $"Podcasts de {ViewBag.PerformerName}";
|
||||
var performerName = ViewBag.PerformerName ?? "ce performer";
|
||||
var permalinkExpiry = ViewBag.PermalinkExpiry is DateTimeOffset expiry ? expiry : DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="alert alert-info d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3" role="alert">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Découvrez les podcasts de @performerName</h1>
|
||||
<p class="mb-0 text-muted">
|
||||
Ce lien public est valable jusqu'au @permalinkExpiry.ToLocalTime().ToString("g").
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" onclick="copyPermalink()">Copier le permalien</button>
|
||||
<a class="btn btn-primary" href="https://www.yavsc.fr/" target="_blank" rel="noreferrer">S'inscrire</a>
|
||||
<a class="btn btn-outline-primary" href="https://postit.yavsc.fr/" target="_blank" rel="noreferrer">Installer PostIt</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model == null || Model.Count == 0)
|
||||
{
|
||||
<div class="alert alert-light border">
|
||||
Aucun podcast public n'est encore disponible pour @performerName.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
@foreach (var podcast in Model)
|
||||
{
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">@podcast.Title</h2>
|
||||
<small class="text-muted">
|
||||
@podcast.CreatedAt.ToLocalTime().ToString("g")
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-secondary">@podcast.State</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 mb-2 text-muted">
|
||||
@podcast.DurationSeconds.ToString("0.##") s · @podcast.SegmentCount segments · @((podcast.Bytes / 1024.0 / 1024.0).ToString("0.##")) Mo
|
||||
</p>
|
||||
|
||||
<audio controls class="w-100 mt-2" preload="metadata">
|
||||
<source src="@Url.Action("Playlist", "Podcast", new { id = podcast.Id })" type="application/vnd.apple.mpegurl" />
|
||||
Votre navigateur ne prend pas en charge la lecture audio.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyPermalink() {
|
||||
const url = '@(ViewBag.Permalink ?? string.Empty)';
|
||||
if (!url) return;
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).catch(() => fallbackCopy(url));
|
||||
return;
|
||||
}
|
||||
|
||||
fallbackCopy(url);
|
||||
}
|
||||
|
||||
function fallbackCopy(value) {
|
||||
const temp = document.createElement('textarea');
|
||||
temp.value = value;
|
||||
temp.setAttribute('readonly', '');
|
||||
temp.style.position = 'fixed';
|
||||
temp.style.left = '-9999px';
|
||||
document.body.appendChild(temp);
|
||||
temp.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(temp);
|
||||
}
|
||||
</script>
|
||||
|
|
@ -2,10 +2,26 @@
|
|||
|
||||
@{
|
||||
ViewBag.Title = (ViewBag.Activity?.Name ?? "Any") ;
|
||||
var activityCode = ViewBag.Activity?.Code as string;
|
||||
var performerId = ViewBag.PerformerId as string;
|
||||
var permalinkExpiry = ViewBag.PermalinkExpiry is DateTimeOffset expiry ? expiry : DateTimeOffset.MinValue;
|
||||
}
|
||||
<h1>@ViewBag.Title</h1>
|
||||
<em>@ViewBag.Activity.Description</em>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(performerId) && !string.IsNullOrWhiteSpace(activityCode))
|
||||
{
|
||||
<div class="alert alert-info d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mt-3" role="alert">
|
||||
<div>
|
||||
<div class="fw-semibold">Permalien public</div>
|
||||
<small class="text-muted">Valable jusqu'au @permalinkExpiry.ToLocalTime().ToString("g")</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="copyPermalink()">
|
||||
Copier le permalien
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var profile in Model) {
|
||||
<hr/>
|
||||
@Html.DisplayFor(m=>profile)
|
||||
|
|
@ -15,3 +31,29 @@
|
|||
</a>
|
||||
}
|
||||
|
||||
<script>
|
||||
function copyPermalink() {
|
||||
const url = '@(ViewBag.Permalink ?? string.Empty)';
|
||||
if (!url) return;
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).catch(() => fallbackCopy(url));
|
||||
return;
|
||||
}
|
||||
|
||||
fallbackCopy(url);
|
||||
}
|
||||
|
||||
function fallbackCopy(value) {
|
||||
const temp = document.createElement('textarea');
|
||||
temp.value = value;
|
||||
temp.setAttribute('readonly', '');
|
||||
temp.style.position = 'fixed';
|
||||
temp.style.left = '-9999px';
|
||||
document.body.appendChild(temp);
|
||||
temp.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(temp);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<li><a class="dropdown-item @PageHelpers.ActivePage(ViewContext, "Feature")" asp-controller="Feature" asp-action="Index">Features</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@if (User.IsInMsRole(Constants.AdminGroupName)) {
|
||||
@if (User.IsInMsRole(Constants.AdminName)) {
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle @PageHelpers.ActivePageAny(ViewContext, administrationControllers)" href="#" id="dropdown05" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Administration
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ namespace Yavsc.Server.Hubs
|
|||
var userId = _dbContext.Users.First(u => u.UserName == userName).Id;
|
||||
|
||||
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
|
||||
isCop = Context.User.IsInMsRole(Constants.AdminGroupName);
|
||||
isCop = Context.User.IsInMsRole(Constants.AdminName);
|
||||
if (isCop)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
|
||||
|
|
@ -232,7 +232,7 @@ namespace Yavsc.Server.Hubs
|
|||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[Authorize(Constants.AdminGroupName)]
|
||||
[Authorize(Constants.AdminName)]
|
||||
public void GLine(string userName, string reason)
|
||||
{
|
||||
if (!InputValidator.ValidateUserName(userName)) return;
|
||||
|
|
@ -330,7 +330,7 @@ namespace Yavsc.Server.Hubs
|
|||
var identityUserName = Context.User.GetUserName();
|
||||
|
||||
if (userName[0] != '?' && Context.User != null)
|
||||
if (!Context.User.IsInMsRole(Constants.AdminGroupName))
|
||||
if (!Context.User.IsInMsRole(Constants.AdminName))
|
||||
{
|
||||
|
||||
var bl = _dbContext.BlackListed
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@ namespace Yavsc.Services
|
|||
try
|
||||
{
|
||||
logger.LogInformation($"SendEmail for {email} : {subject}");
|
||||
if (string.IsNullOrWhiteSpace(siteSettings?.Owner?.EMail)
|
||||
|| string.IsNullOrWhiteSpace(smtpSettings?.Host))
|
||||
{
|
||||
logger.LogError("Email not sent: mail configuration is incomplete (Site:Owner:EMail and Smtp:Host are required). To={To}, Subject={Subject}", email, subject);
|
||||
return string.Empty;
|
||||
}
|
||||
MimeMessage msg = new();
|
||||
msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail));
|
||||
msg.To.Add(BuildMailboxAddress(name, email));
|
||||
|
|
@ -139,7 +145,7 @@ namespace Yavsc.Services
|
|||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings.Owner.EMail);
|
||||
logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings?.Owner?.EMail);
|
||||
logger.LogError(ex.Message);
|
||||
if (ex.InnerException!=null)
|
||||
logger.LogError(ex.InnerException.Message);
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName);
|
||||
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminName);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ using Microsoft.Extensions.FileProviders;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Models.Messaging;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Controllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Workflow;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
|
@ -57,6 +59,106 @@ public sealed class HomeControllerTests : IClassFixture<ApiWebServerFixture>
|
|||
Assert.DoesNotContain(model, a => a.Code == "ghost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_does_not_show_performer_notification_to_anonymous_visitors()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||
db.Notification.AddRange(
|
||||
Note("Rendez-vous professionnel", "user/alice"),
|
||||
Note("Annonce publique", "pub/all"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateHomeController(db, new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
controller.ControllerContext.HttpContext.Session = new TestSession();
|
||||
|
||||
await controller.Index(id: null);
|
||||
|
||||
object notifyValue = controller.ViewBag.Notify;
|
||||
var notifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(notifyValue);
|
||||
Assert.Equal("Annonce publique", Assert.Single(notifications).title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_shows_performer_notification_only_to_its_target_user()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||
db.Notification.AddRange(
|
||||
Note("Rendez-vous professionnel", "user/alice"),
|
||||
Note("Autre rendez-vous", "user/bob"),
|
||||
Note("Annonce publique", "pub/all"));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateHomeController(db, UserPrincipal("bob"));
|
||||
await controller.Index(id: null);
|
||||
|
||||
object notifyValue = controller.ViewBag.Notify;
|
||||
var notifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(notifyValue);
|
||||
Assert.Equal(new[] { "Autre rendez-vous", "Annonce publique" },
|
||||
notifications.Select(notification => notification.title));
|
||||
|
||||
var performerController = CreateHomeController(db, UserPrincipal("alice"));
|
||||
await performerController.Index(id: null);
|
||||
|
||||
object performerNotifyValue = performerController.ViewBag.Notify;
|
||||
var performerNotifications = Assert.IsAssignableFrom<IEnumerable<Notification>>(performerNotifyValue);
|
||||
Assert.Equal(new[] { "Rendez-vous professionnel", "Annonce publique" },
|
||||
performerNotifications.Select(notification => notification.title));
|
||||
}
|
||||
|
||||
private static HomeController CreateHomeController(
|
||||
Yavsc.Models.ApplicationDbContext db,
|
||||
ClaimsPrincipal user)
|
||||
{
|
||||
var controller = new HomeController(
|
||||
NullLogger<HomeController>.Instance,
|
||||
localizer: null!,
|
||||
context: db,
|
||||
settingsOptions: Options.Create(new SiteSettings()),
|
||||
env: new TestEnvironment());
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = user }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal UserPrincipal(string userId)
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("sub", userId),
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "Bearer"));
|
||||
|
||||
private static Notification Note(string title, string target)
|
||||
=> new()
|
||||
{
|
||||
title = title,
|
||||
body = "Corps",
|
||||
click_action = "Fermer",
|
||||
Target = target
|
||||
};
|
||||
|
||||
private sealed class TestSession : ISession
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _values = new();
|
||||
|
||||
public bool IsAvailable => true;
|
||||
public string Id => "home-controller-test-session";
|
||||
public IEnumerable<string> Keys => _values.Keys;
|
||||
|
||||
public void Clear() => _values.Clear();
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public void Remove(string key) => _values.Remove(key);
|
||||
public void Set(string key, byte[] value) => _values[key] = value;
|
||||
public bool TryGetValue(string key, out byte[] value) => _values.TryGetValue(key, out value!);
|
||||
}
|
||||
|
||||
private sealed class TestEnvironment : IWebHostEnvironment
|
||||
{
|
||||
public string ApplicationName { get; set; } = "Yavsc.Api.Test";
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ public sealed class InvoiceApiTests(ApiWebServerFixture fixture) : IClassFixture
|
|||
using var admin = Client();
|
||||
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
|
||||
TestTokenIssuer.Issue("alice", "api", extraClaims:
|
||||
[new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminGroupName)]));
|
||||
[new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminName)]));
|
||||
var response = await admin.GetAsync("/openapi/invoicing.json", Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var json = await response.Content.ReadAsStringAsync(Ct);
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public sealed class PodcastControllerTests(ApiWebServerFixture fixture) : IClass
|
|||
using var admin = Client("podcast-admin");
|
||||
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
|
||||
TestTokenIssuer.Issue("podcast-admin", "api",
|
||||
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminGroupName) }));
|
||||
extraClaims: new[] { new Claim(Constants.RoleClaimType, Constants.AdminName) }));
|
||||
using var response = await admin.GetAsync("/openapi/podcasts.json", _ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(_ct));
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
|
|||
title = "Admin",
|
||||
body = "Restricted to administrators",
|
||||
click_action = "/admin",
|
||||
Target = "administration",
|
||||
Target = Constants.Administration,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
|
|||
"api",
|
||||
new[]
|
||||
{
|
||||
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName)
|
||||
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminName)
|
||||
});
|
||||
|
||||
var response = await adminHttp.GetAsync("/api/v1/resource-usage/admin", TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,22 @@ namespace Yavsc.Org.Tests
|
|||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_skips_when_owner_is_not_configured()
|
||||
{
|
||||
var factory = new RejectingSmtpClientFactory();
|
||||
var sender = new MailSender(
|
||||
Options.Create(new SiteSettings { Authority = "example.com" }),
|
||||
Options.Create(new SmtpSettings()),
|
||||
NullLoggerFactory.Instance,
|
||||
new TestStringLocalizer(),
|
||||
factory);
|
||||
|
||||
var result = await sender.SendEmailAsync("Alice", "alice@example.com", "Rdv", "hello");
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
private sealed class RejectingSmtpClientFactory : ISmtpClientFactory
|
||||
{
|
||||
public Yavsc.Interfaces.ISmtpClient CreateClient() => new RejectingSmtpClient();
|
||||
|
|
|
|||
Loading…
Reference in a new issue