Neuron®
The Neuron® is the basis for the creation of open and secure federated networks for smart societies.
Loading...
Searching...
No Matches
ThemeService.cs
1using System.Net;
3using System.Text;
4using System.Threading;
5using System.Xml;
6using CommunityToolkit.Maui.Core;
11
13{
22 [Singleton]
23 public sealed class ThemeService : IThemeService, IDisposable
24 {
25 private const string providerFlagKey = "IsServerThemeDictionary";
26 private const string localFlagKey = "IsLocalThemeDictionary";
27 private static readonly TimeSpan themeExpiry = Constants.Cache.DefaultImageCache;
28
29 // Keys used for validation (registered in MauiProgram) now use the namespace URNs directly.
30 private static readonly string brandingSchemaKeyV1 = Constants.Schemes.NeuroAccessBrandingV1;
31
32 // Provider theme application state & refresh
33 private enum ProviderThemeStatus { NotStarted, InProgress, Applied, NotSupported, FailedPermanent, FailedTransient }
34 private enum BrandingFetchClassification { Success, NotFound, TransientFailure, PermanentFailure }
35 private ProviderThemeStatus providerThemeState = ProviderThemeStatus.NotStarted;
36 private string? lastDomainAttempted;
37 private Task? backgroundRefreshTask;
38 private const string unsupportedCachePrefix = "BrandingUnsupported:";
39 private static readonly TimeSpan blockingFetchTimeout = TimeSpan.FromSeconds(6);
40 private static readonly TimeSpan backgroundFetchTimeout = TimeSpan.FromSeconds(2);
41 private static readonly TimeSpan manualFetchTimeout = TimeSpan.FromSeconds(8);
42 private static readonly TimeSpan probeTimeout = TimeSpan.FromSeconds(2);
43 private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(5) };
44
45 private readonly FileCacheManager cacheManager;
46 private readonly Dictionary<string, Uri> imageUrisMap;
47 private ResourceDictionary? localLightDict = new Light();
48 private ResourceDictionary? localDarkDict = new Dark();
49 private AppTheme? lastAppliedLocalTheme;
50 private bool disposedValue;
51
55 public ThemeService()
56 {
57 this.cacheManager = new FileCacheManager("BrandingThemes", themeExpiry);
58 this.imageUrisMap = new(StringComparer.OrdinalIgnoreCase);
59 }
60
64 public IReadOnlyDictionary<string, Uri> ImageUris => new Dictionary<string, Uri>(this.imageUrisMap);
65
69 public Task<AppTheme> GetTheme() => Task.FromResult(Application.Current?.UserAppTheme ?? AppTheme.Unspecified);
70
74 public TaskCompletionSource ThemeLoaded { get; } = new();
75
81 public void SetTheme(AppTheme Theme)
82 {
83 MainThread.BeginInvokeOnMainThread(() =>
84 {
85 try
86 {
87 ServiceRef.TagProfile.Theme = Theme;
88 Application.Current!.UserAppTheme = Theme;
89 this.SetLocalTheme(Theme);
90 // Platform specific status bar adjustments (iOS & MacCatalyst with supported versions)
91#if IOS || MACCATALYST
92 try
93 {
94 CommunityToolkit.Maui.Core.Platform.StatusBar.SetColor(AppColors.PrimaryBackground);
95 CommunityToolkit.Maui.Core.Platform.StatusBar.SetStyle(Theme == AppTheme.Dark ? StatusBarStyle.LightContent : StatusBarStyle.DarkContent);
96 }
97 catch (Exception)
98 {
99 // Non-fatal: ignore status bar styling issues on unsupported platforms/versions.
100 }
101#endif
102 }
103 catch (Exception Ex)
104 { ServiceRef.LogService.LogException(new Exception($"SetTheme failed for theme {Theme}.", Ex)); }
105 });
106 }
107
112 public void SetLocalTheme(AppTheme Theme)
113 {
114 if (Theme == AppTheme.Unspecified)
115 Theme = Application.Current?.RequestedTheme ?? AppTheme.Unspecified;
116
117 MainThread.BeginInvokeOnMainThread(() =>
118 {
119 try
120 {
121 ICollection<ResourceDictionary> Merged = Application.Current!.Resources.MergedDictionaries;
122 this.RemoveAllThemeDictionaries(Merged, this.localLightDict);
123 this.RemoveAllThemeDictionaries(Merged, this.localDarkDict);
124 this.localLightDict ??= new Light();
125 this.localDarkDict ??= new Dark();
126 if (Theme == AppTheme.Dark && !Merged.Contains(this.localDarkDict))
127 Merged.Add(this.localDarkDict);
128 else if (Theme != AppTheme.Dark && !Merged.Contains(this.localLightDict))
129 Merged.Add(this.localLightDict);
130 this.lastAppliedLocalTheme = Theme;
131 }
132 catch (Exception Ex)
133 { ServiceRef.LogService.LogException(new Exception($"SetLocalTheme failed for theme {Theme}.", Ex)); }
134 });
135 }
136
140 public async Task ApplyProviderThemeAsync()
141 {
142 await this.ApplyProviderThemeAsync(ThemeFetchPolicy.BackgroundRefresh, CancellationToken.None);
143 }
144
151 public async Task<ThemeApplyOutcome> ApplyProviderThemeAsync(ThemeFetchPolicy Policy, CancellationToken CancellationToken = default)
152 {
153 string? Domain = ServiceRef.TagProfile.Domain;
154 if (string.IsNullOrWhiteSpace(Domain))
155 {
156 ServiceRef.LogService.LogDebug("ApplyProviderThemeAsync: Skipped (no domain). ");
157 this.ThemeLoaded.TrySetResult();
158 return ThemeApplyOutcome.SkippedNoDomain;
159 }
160
161 if (this.lastDomainAttempted is not null && this.lastDomainAttempted.Equals(Domain, StringComparison.OrdinalIgnoreCase) &&
162 this.providerThemeState is ProviderThemeStatus.Applied or ProviderThemeStatus.NotSupported or ProviderThemeStatus.FailedPermanent)
163 {
164 ServiceRef.LogService.LogDebug($"ApplyProviderThemeAsync: Already finalized for {Domain} state={this.providerThemeState}.");
165 this.ThemeLoaded.TrySetResult();
166 return this.providerThemeState switch
167 {
168 ProviderThemeStatus.NotSupported => ThemeApplyOutcome.NotSupported,
169 ProviderThemeStatus.FailedPermanent => ThemeApplyOutcome.FailedPermanent,
170 _ => ThemeApplyOutcome.AppliedFromCache
171 };
172 }
173
174 this.providerThemeState = ProviderThemeStatus.InProgress;
175 this.lastDomainAttempted = Domain;
176
177 try
178 {
179 if (Policy != ThemeFetchPolicy.ManualRefresh && await this.IsBrandingUnsupportedAsync(Domain))
180 {
181 ServiceRef.LogService.LogDebug($"ApplyProviderThemeAsync: Unsupported cached for {Domain}.");
182 this.providerThemeState = ProviderThemeStatus.NotSupported;
183 await this.SetLocalThemeFromBackgroundThread();
184 return ThemeApplyOutcome.NotSupported;
185 }
186
187 (bool AppliedFromCache, bool CacheExpired) = await this.TryApplyCachedProviderThemeAsync(Domain, Policy, CancellationToken);
188 if (AppliedFromCache)
189 {
190 this.providerThemeState = ProviderThemeStatus.Applied;
191 if (Policy == ThemeFetchPolicy.ManualRefresh)
192 {
193 ThemeApplyOutcome RefreshOutcome = await this.FetchAndApplyProviderThemeAsync(Domain, Policy, false, CancellationToken, true);
194 return RefreshOutcome == ThemeApplyOutcome.Applied ? ThemeApplyOutcome.Applied : ThemeApplyOutcome.AppliedFromCache;
195 }
196 if (CacheExpired)
197 this.StartBackgroundRefresh(Domain);
198 return ThemeApplyOutcome.AppliedFromCache;
199 }
200
201 if (Policy == ThemeFetchPolicy.BackgroundRefresh)
202 {
203 this.StartBackgroundRefresh(Domain);
204 this.providerThemeState = ProviderThemeStatus.FailedTransient;
205 await this.SetLocalThemeFromBackgroundThread();
206 return ThemeApplyOutcome.FailedTransient;
207 }
208
209 bool ForceNetwork = Policy == ThemeFetchPolicy.ManualRefresh;
210 ThemeApplyOutcome Outcome = await this.FetchAndApplyProviderThemeAsync(Domain, Policy, true, CancellationToken, ForceNetwork);
211 this.providerThemeState = Outcome switch
212 {
213 ThemeApplyOutcome.Applied => ProviderThemeStatus.Applied,
214 ThemeApplyOutcome.AppliedFromCache => ProviderThemeStatus.Applied,
215 ThemeApplyOutcome.NotSupported => ProviderThemeStatus.NotSupported,
216 ThemeApplyOutcome.FailedPermanent => ProviderThemeStatus.FailedPermanent,
217 ThemeApplyOutcome.FailedTransient => ProviderThemeStatus.FailedTransient,
218 _ => this.providerThemeState
219 };
220 return Outcome;
221 }
222 finally
223 {
224 this.ThemeLoaded.TrySetResult();
225 }
226 }
227
228 private async Task SetLocalThemeFromBackgroundThread()
229 {
230 try
231 {
232 this.SetLocalTheme(await this.GetTheme());
233 }
234 catch (Exception Ex)
235 {
236 ServiceRef.LogService.LogException(new Exception("SetLocalThemeFromBackgroundThread failed.", Ex));
237 }
238 }
239
240 private void StartBackgroundRefresh(string Domain)
241 {
242 if (!ServiceRef.NetworkService.IsOnline)
243 return;
244 if (this.backgroundRefreshTask is not null && !this.backgroundRefreshTask.IsCompleted)
245 return;
246 this.backgroundRefreshTask = Task.Run(async () =>
247 {
248 try
249 {
250 ThemeApplyOutcome Outcome = await this.FetchAndApplyProviderThemeAsync(Domain, ThemeFetchPolicy.BackgroundRefresh, false, CancellationToken.None, true);
251 if (Outcome == ThemeApplyOutcome.Applied)
252 this.providerThemeState = ProviderThemeStatus.Applied;
253 }
254 catch (Exception Ex)
255 {
256 ServiceRef.LogService.LogException(new Exception($"Background provider theme refresh failed for {Domain}.", Ex));
257 }
258 });
259 }
260
261 private async Task<(bool Applied, bool CacheExpired)> TryApplyCachedProviderThemeAsync(string Domain, ThemeFetchPolicy Policy, CancellationToken CancellationToken)
262 {
263 (bool Applied, bool CacheExpired) = await this.TryApplyCachedBrandingAsync(Domain, true, Policy, CancellationToken);
264 if (Applied)
265 return (true, CacheExpired);
266 return await this.TryApplyCachedBrandingAsync(Domain, false, Policy, CancellationToken);
267 }
268
269 private async Task<(bool Applied, bool CacheExpired)> TryApplyCachedBrandingAsync(string Domain, bool IsV2, ThemeFetchPolicy Policy, CancellationToken CancellationToken)
270 {
271 (XmlDocument? Document, bool IsExpired) = await this.TryGetCachedBrandingXmlAsync(Domain, IsV2);
272 if (Document is null)
273 return (false, false);
274
275 if (IsV2)
276 await this.ApplyV2Async(Document, Policy, CancellationToken, false);
277 else
278 await this.ApplyV1Async(Document, Policy, CancellationToken, false);
279
280 return (true, IsExpired);
281 }
282
283 private async Task<ThemeApplyOutcome> FetchAndApplyProviderThemeAsync(string Domain, ThemeFetchPolicy Policy, bool ApplyFallbackOnFailure, CancellationToken CancellationToken, bool ForceNetwork)
284 {
285 if (!ServiceRef.NetworkService.IsOnline)
286 return this.HandleFetchFailure(ThemeApplyOutcome.FailedTransient, ApplyFallbackOnFailure);
287
288 (bool Success, XmlDocument? Document, BrandingFetchClassification Classification) V2 = await this.TryGetBrandingXmlAsync(Domain, true, Policy, ForceNetwork, CancellationToken);
289 if (V2.Success && V2.Document is not null)
290 {
291 await this.ApplyV2Async(V2.Document, Policy, CancellationToken, ForceNetwork);
292 await this.ClearBrandingUnsupportedAsync(Domain);
293 return ThemeApplyOutcome.Applied;
294 }
295 if (V2.Classification == BrandingFetchClassification.TransientFailure)
296 return this.HandleFetchFailure(ThemeApplyOutcome.FailedTransient, ApplyFallbackOnFailure);
297 if (V2.Classification == BrandingFetchClassification.PermanentFailure)
298 return this.HandleFetchFailure(ThemeApplyOutcome.FailedPermanent, ApplyFallbackOnFailure);
299
300 (bool Success, XmlDocument? Document, BrandingFetchClassification Classification) V1 = await this.TryGetBrandingXmlAsync(Domain, false, Policy, ForceNetwork, CancellationToken);
301 if (V1.Success && V1.Document is not null)
302 {
303 await this.ApplyV1Async(V1.Document, Policy, CancellationToken, ForceNetwork);
304 await this.ClearBrandingUnsupportedAsync(Domain);
305 return ThemeApplyOutcome.Applied;
306 }
307
308 return V1.Classification switch
309 {
310 BrandingFetchClassification.NotFound => await this.HandleNotSupportedAsync(Domain, ApplyFallbackOnFailure),
311 BrandingFetchClassification.TransientFailure => this.HandleFetchFailure(ThemeApplyOutcome.FailedTransient, ApplyFallbackOnFailure),
312 _ => this.HandleFetchFailure(ThemeApplyOutcome.FailedPermanent, ApplyFallbackOnFailure)
313 };
314 }
315
316 private async Task<ThemeApplyOutcome> HandleNotSupportedAsync(string Domain, bool ApplyFallbackOnFailure)
317 {
318 await this.MarkBrandingUnsupportedAsync(Domain);
319 if (ApplyFallbackOnFailure)
320 await this.SetLocalThemeFromBackgroundThread();
321 return ThemeApplyOutcome.NotSupported;
322 }
323
324 private ThemeApplyOutcome HandleFetchFailure(ThemeApplyOutcome Outcome, bool ApplyFallbackOnFailure)
325 {
326 if (ApplyFallbackOnFailure)
327 {
328 _ = this.SetLocalThemeFromBackgroundThread();
329 }
330 return Outcome;
331 }
332
333 private async Task<bool> IsBrandingUnsupportedAsync(string Domain)
334 {
335 string Key = GetUnsupportedCacheKey(Domain);
336 (byte[]? Data, string _, bool IsExpired) = await this.cacheManager.TryGetWithExpiry(Key);
337 return Data is not null && !IsExpired;
338 }
339
340 private async Task MarkBrandingUnsupportedAsync(string Domain)
341 {
342 string Key = GetUnsupportedCacheKey(Domain);
343 byte[] Data = Encoding.ASCII.GetBytes("1");
344 await this.cacheManager.AddOrUpdate(Key, Domain, false, Data, "text/plain");
345 }
346
347 private Task<bool> ClearBrandingUnsupportedAsync(string Domain)
348 {
349 string Key = GetUnsupportedCacheKey(Domain);
350 return this.cacheManager.Remove(Key);
351 }
352
353 private static string GetUnsupportedCacheKey(string Domain)
354 {
355 string KeyDomain = Domain.ToLowerInvariant();
356 return $"{unsupportedCachePrefix}{KeyDomain}";
357 }
358
359 private static TimeSpan GetFetchTimeout(ThemeFetchPolicy Policy)
360 {
361 return Policy switch
362 {
363 ThemeFetchPolicy.BlockingFirstRun => blockingFetchTimeout,
364 ThemeFetchPolicy.ManualRefresh => manualFetchTimeout,
365 _ => backgroundFetchTimeout
366 };
367 }
368
369 private async Task<(XmlDocument? Document, bool IsExpired)> TryGetCachedBrandingXmlAsync(string Domain, bool IsV2)
370 {
371 Uri Uri = BuildBrandingItemUrl(Domain, IsV2 ? "BrandingV2" : "Branding");
372 string Key = Uri.AbsoluteUri;
373 (byte[]? Cached, string _, bool IsExpired) = await this.cacheManager.TryGetWithExpiry(Key);
374 if (Cached is null)
375 return (null, false);
376
377 string XmlString = Encoding.UTF8.GetString(Cached);
378 bool Valid = await this.ValidateBrandingXmlAsync(XmlString, IsV2, Domain);
379 if (!Valid)
380 {
381 await this.cacheManager.Remove(Key);
382 return (null, false);
383 }
384 try
385 {
386 XmlDocument Doc = new();
387 Doc.LoadXml(XmlString);
388 return (Doc, IsExpired);
389 }
390 catch (Exception Ex)
391 {
392 ServiceRef.LogService.LogException(new Exception($"TryGetCachedBrandingXmlAsync parse {(IsV2 ? "V2" : "V1")} {Uri}", Ex));
393 await this.cacheManager.Remove(Key);
394 return (null, false);
395 }
396 }
397
398 private async Task<(bool Success, XmlDocument? Document, BrandingFetchClassification Classification)> TryGetBrandingXmlAsync(string Domain, bool IsV2, ThemeFetchPolicy Policy, bool ForceNetwork, CancellationToken CancellationToken)
399 {
400 Uri Uri = BuildBrandingItemUrl(Domain, IsV2 ? "BrandingV2" : "Branding");
401 string Key = Uri.AbsoluteUri;
402 (byte[]? Bytes, bool _, bool AttemptedNetwork) = await this.FetchOrGetCachedAsync(Uri, Key, Policy, ForceNetwork, CancellationToken);
403 if (Bytes is not null)
404 {
405 string XmlString = Encoding.UTF8.GetString(Bytes);
406 bool Valid = await this.ValidateBrandingXmlAsync(XmlString, IsV2, Domain);
407 if (!Valid)
408 {
409 await this.cacheManager.Remove(Key);
410 return (false, null, BrandingFetchClassification.PermanentFailure);
411 }
412 try
413 {
414 XmlDocument Doc = new();
415 Doc.LoadXml(XmlString);
416 return (true, Doc, BrandingFetchClassification.Success);
417 }
418 catch (Exception Ex)
419 {
420 ServiceRef.LogService.LogException(new Exception($"TryGetBrandingXmlAsync parse {(IsV2 ? "V2" : "V1")} {Uri}", Ex));
421 await this.cacheManager.Remove(Key);
422 return (false, null, BrandingFetchClassification.PermanentFailure);
423 }
424 }
425 if (!AttemptedNetwork)
426 return (false, null, BrandingFetchClassification.TransientFailure);
427
428 TimeSpan ProbeTimeout = GetFetchTimeout(Policy);
429 if (ProbeTimeout > probeTimeout)
430 ProbeTimeout = probeTimeout;
431 HttpStatusCode Probe = await ProbeUriAsync(Uri, ProbeTimeout, CancellationToken);
432 return Probe switch
433 {
434 HttpStatusCode.NotFound => (false, null, BrandingFetchClassification.NotFound),
435 HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout or HttpStatusCode.BadGateway => (false, null, BrandingFetchClassification.TransientFailure),
436 HttpStatusCode.RequestTimeout => (false, null, BrandingFetchClassification.TransientFailure),
437 0 => (false, null, BrandingFetchClassification.PermanentFailure),
438 _ => (false, null, BrandingFetchClassification.PermanentFailure)
439 };
440 }
441
442 private static async Task<HttpStatusCode> ProbeUriAsync(Uri Uri, TimeSpan Timeout, CancellationToken CancellationToken)
443 {
444 try
445 {
446 using CancellationTokenSource LinkedCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
447 LinkedCts.CancelAfter(Timeout);
448 using HttpRequestMessage Req = new HttpRequestMessage(HttpMethod.Head, Uri);
449 using HttpResponseMessage Resp = await httpClient.SendAsync(Req, LinkedCts.Token);
450 return Resp.StatusCode;
451 }
452 catch (HttpRequestException Ex) when (IsTransientNetwork(Ex))
453 {
454 return 0;
455 }
456 catch (OperationCanceledException)
457 {
458 return 0;
459 }
460 catch (Exception)
461 {
462 return 0;
463 }
464 }
465 private static bool IsTransientNetwork(Exception Ex) => Ex is HttpRequestException or SocketException or TaskCanceledException or TimeoutException;
466
467 private static Uri BuildBrandingItemUrl(string Domain, string ItemId) => ServiceRef.TagProfile.DomainIsLocal() ?
468 new($"http://{Domain}/PubSub/NeuroAccessBranding/{ItemId}")
469 :
470 new($"https://{Domain}/PubSub/NeuroAccessBranding/{ItemId}");
471
476 public string GetImageUri(string Id) => this.imageUrisMap.TryGetValue(Id, out Uri? ImageUri) ? ImageUri.AbsoluteUri : string.Empty;
477
478 private async Task ApplyV2Async(XmlDocument Doc, ThemeFetchPolicy Policy, CancellationToken CancellationToken, bool ForceNetwork)
479 {
480 XmlElement? Root = Doc.DocumentElement;
481 if (Root is null)
482 return;
483 if (System.Array.IndexOf(Constants.Schemes.BrandingV2NamespaceKeys, Root.NamespaceURI) < 0)
484 return;
485 this.imageUrisMap.Clear();
486 foreach (XmlElement Node in Root.SelectNodes("//*[local-name()='ImageRef']")?.OfType<XmlElement>() ?? [])
487 {
488 string Id = Node.GetAttribute("id");
489 string UriText = Node.GetAttribute("uri");
490 if (!string.IsNullOrEmpty(Id) && !string.IsNullOrEmpty(UriText) && Uri.TryCreate(UriText, UriKind.Absolute, out Uri? ImgUri))
491 this.imageUrisMap[Id] = ImgUri;
492 }
493 Uri? LightUri = null;
494 Uri? DarkUri = null;
495 foreach (XmlElement Node in Root.SelectNodes("//*[local-name()='ColorsUri']")?.OfType<XmlElement>() ?? [])
496 {
497 string ThemeName = Node.GetAttribute("theme").ToLowerInvariant();
498 string UriText2 = Node.InnerText.Trim();
499 if (ThemeName == "light")
500 LightUri = new Uri(UriText2);
501 else if (ThemeName == "dark")
502 DarkUri = new Uri(UriText2);
503 }
504 await MainThread.InvokeOnMainThreadAsync(async () =>
505 {
506 ICollection<ResourceDictionary>? Merged = Application.Current?.Resources.MergedDictionaries;
507 if (Merged is null)
508 return;
509 this.RemoveAllThemeDictionaries(Merged, this.localLightDict);
510 this.RemoveAllThemeDictionaries(Merged, this.localDarkDict);
511 this.localLightDict ??= new Light();
512 this.localDarkDict ??= new Dark();
513 this.localLightDict.Clear();
514 this.localDarkDict.Clear();
515 if (LightUri is not null)
516 {
517 ResourceDictionary? LightDict = await this.LoadProviderDictionaryAsync(LightUri, "Light", Policy, CancellationToken, ForceNetwork);
518 if (LightDict is not null) foreach (string K in LightDict.Keys.OfType<string>()) this.localLightDict[K] = LightDict[K];
519 }
520 if (DarkUri is not null)
521 {
522 ResourceDictionary? DarkDict = await this.LoadProviderDictionaryAsync(DarkUri, "Dark", Policy, CancellationToken, ForceNetwork);
523 if (DarkDict is not null) foreach (string K in DarkDict.Keys.OfType<string>()) this.localDarkDict[K] = DarkDict[K];
524 }
525 this.localLightDict[localFlagKey] = true;
526 this.localDarkDict[localFlagKey] = true;
527 AppTheme Current = await this.GetTheme();
528 if (Current == AppTheme.Dark && !Merged.Contains(this.localDarkDict))
529 Merged.Add(this.localDarkDict);
530 else if (Current != AppTheme.Dark && !Merged.Contains(this.localLightDict))
531 Merged.Add(this.localLightDict);
532 this.lastAppliedLocalTheme = Current;
533 });
534 }
535
536 private async Task ApplyV1Async(XmlDocument Doc, ThemeFetchPolicy Policy, CancellationToken CancellationToken, bool ForceNetwork)
537 {
538 XmlElement? Root = Doc.DocumentElement;
539 if (Root is null || Root.NamespaceURI != Constants.Schemes.NeuroAccessBrandingV1)
540 return;
541 this.imageUrisMap.Clear();
542 foreach (XmlElement Node in Root.SelectNodes("//*[local-name()='ImageRef']")?.OfType<XmlElement>() ?? [])
543 {
544 string Id = Node.GetAttribute("id"); string UriText = Node.GetAttribute("uri");
545 if (!string.IsNullOrEmpty(Id) && !string.IsNullOrEmpty(UriText) && Uri.TryCreate(UriText, UriKind.Absolute, out Uri? ImgUri)) this.imageUrisMap[Id] = ImgUri;
546 }
547 if (Root.SelectSingleNode("//*[local-name()='ColorsUri']") is not XmlElement ColorsNode)
548 return;
549 Uri ColorsUri = new(ColorsNode.InnerText.Trim());
550 ResourceDictionary? Orig = await this.LoadProviderDictionaryAsync(ColorsUri, "V1", Policy, CancellationToken, ForceNetwork);
551 if (Orig is null)
552 return;
553 Dictionary<string, object> Light = [];
554 Dictionary<string, object> Dark = [];
555 foreach (string K in Orig.Keys.OfType<string>())
556 {
557 if (K.EndsWith("Light", StringComparison.OrdinalIgnoreCase)) Light[K[..^5]] = Orig[K];
558 else if (K.EndsWith("Dark", StringComparison.OrdinalIgnoreCase)) Dark[K[..^4]] = Orig[K];
559 else { Light[K] = Orig[K]; Dark[K] = Orig[K]; }
560 }
561 await MainThread.InvokeOnMainThreadAsync(async () =>
562 {
563 ICollection<ResourceDictionary>? Merged = Application.Current?.Resources.MergedDictionaries;
564 if (Merged is null)
565 return;
566 this.RemoveAllThemeDictionaries(Merged, this.localLightDict);
567 this.RemoveAllThemeDictionaries(Merged, this.localDarkDict);
568 this.localLightDict ??= new Light(); this.localDarkDict ??= new Dark();
569 this.localLightDict.Clear();
570 this.localDarkDict.Clear();
571 foreach (KeyValuePair<string, object> Kv in Light)
572 this.localLightDict[Kv.Key] = Kv.Value;
573 foreach (KeyValuePair<string, object> Kv in Dark)
574 this.localDarkDict[Kv.Key] = Kv.Value;
575 this.localLightDict[localFlagKey] = true;
576 this.localDarkDict[localFlagKey] = true;
577 AppTheme Current = await this.GetTheme();
578 if (Current == AppTheme.Dark && !Merged.Contains(this.localDarkDict))
579 Merged.Add(this.localDarkDict);
580 else if (Current != AppTheme.Dark && !Merged.Contains(this.localLightDict))
581 Merged.Add(this.localLightDict);
582 this.lastAppliedLocalTheme = Current;
583 });
584 }
585
586 private async Task<ResourceDictionary?> LoadProviderDictionaryAsync(Uri Uri, string Tag, ThemeFetchPolicy Policy, CancellationToken CancellationToken, bool ForceNetwork)
587 {
588 (byte[]? Bytes, bool _, bool _) = await this.FetchOrGetCachedAsync(Uri, Uri.AbsoluteUri, Policy, ForceNetwork, CancellationToken);
589 if (Bytes is null)
590 return null;
591 try
592 {
593 ResourceDictionary Dict = new ResourceDictionary().LoadFromXaml(Encoding.UTF8.GetString(Bytes));
594 Dict.TryAdd(providerFlagKey, true); Dict.TryAdd("Theme", Tag);
595 return Dict;
596 }
597 catch (Exception Ex)
598 {
599 ServiceRef.LogService.LogException(new Exception($"Failed to load provider ResourceDictionary from {Uri}", Ex));
600 return null;
601 }
602 }
603
604 private async Task<(byte[]? Data, bool CacheExpired, bool AttemptedNetwork)> FetchOrGetCachedAsync(Uri Uri, string Key, ThemeFetchPolicy Policy, bool ForceNetwork, CancellationToken CancellationToken)
605 {
606 try
607 {
608 if (CancellationToken.IsCancellationRequested)
609 return (null, false, false);
610 (byte[]? Cached, string _, bool IsExpired) = await this.cacheManager.TryGetWithExpiry(Key);
611 if (Cached is not null && !ForceNetwork)
612 return (Cached, IsExpired, false);
613
614 if (!ServiceRef.NetworkService.IsOnline)
615 return (Cached, IsExpired, false);
616
617 TimeSpan Timeout = GetFetchTimeout(Policy);
618 (byte[]? Fetched, _) = await ServiceRef.InternetCacheService.GetOrFetch(Uri, ServiceRef.TagProfile.PubSubJid!, false, Timeout);
619 if (Fetched is not null)
620 {
621 await this.cacheManager.AddOrUpdate(Key, ServiceRef.TagProfile.PubSubJid!, false, Fetched, "application/xml");
622 return (Fetched, false, true);
623 }
624 return (Cached, IsExpired, true);
625 }
626 catch (Exception Ex)
627 {
628 ServiceRef.LogService.LogException(new Exception($"FetchOrGetCachedAsync failed for {Uri}", Ex));
629 return (null, false, false);
630 }
631 }
632
633 private async Task<bool> ValidateBrandingXmlAsync(string Xml, bool V2, string Domain)
634 {
635 try
636 {
637 if (V2)
638 {
639 foreach (string SchemaKey in Constants.Schemes.BrandingV2NamespaceKeys)
640 {
641 bool Valid = await ServiceRef.XmlSchemaValidationService.ValidateAsync(SchemaKey, Xml).ConfigureAwait(false);
642 if (!Valid)
643 continue;
644 if (string.Equals(SchemaKey, Constants.Schemes.NeuroAccessBrandingV2Url, StringComparison.Ordinal))
645 {
646 ServiceRef.LogService.LogDebug("BrandingXmlValidationPrimarySuccess", new KeyValuePair<string, object?>("Domain", Domain));
647 }
648 else
649 {
650 ServiceRef.LogService.LogInformational(
651 "BrandingXmlValidationFallbackSuccess",
652 new KeyValuePair<string, object?>("Domain", Domain),
653 new KeyValuePair<string, object?>("LegacyKey", SchemaKey));
654 }
655 return true;
656 }
657
658 ServiceRef.LogService.LogWarning(
659 "BrandingXmlValidationBothFailed",
660 new KeyValuePair<string, object?>("Domain", Domain),
661 new KeyValuePair<string, object?>("PrimaryKey", Constants.Schemes.NeuroAccessBrandingV2Url),
662 new KeyValuePair<string, object?>("LegacyKey", Constants.Schemes.NeuroAccessBrandingV2));
663 return false;
664 }
665
666 bool ValidV1 = await ServiceRef.XmlSchemaValidationService.ValidateAsync(brandingSchemaKeyV1, Xml).ConfigureAwait(false);
667 if (!ValidV1)
668 {
669 ServiceRef.LogService.LogWarning(
670 "BrandingXmlValidationV1Failed",
671 new KeyValuePair<string, object?>("Domain", Domain),
672 new KeyValuePair<string, object?>("Key", brandingSchemaKeyV1));
673 }
674 return ValidV1;
675 }
676 catch (Exception Ex)
677 {
678 ServiceRef.LogService.LogException(new Exception($"Branding XML validation failed for schema version {(V2 ? "V2" : "V1")}.", Ex), new KeyValuePair<string, object?>("Domain", Domain));
679 return false;
680 }
681 }
682
683 private void RemoveAllThemeDictionaries(ICollection<ResourceDictionary> Merged, ResourceDictionary? Instance)
684 {
685 if (Instance is not null) while (Merged.Contains(Instance)) Merged.Remove(Instance);
686 foreach (ResourceDictionary? D in Merged.Where(D => D.ContainsKey(localFlagKey) || D.ContainsKey(providerFlagKey)).ToList()) Merged.Remove(D);
687 }
688
692 public async Task<int> ClearBrandingCacheForCurrentDomain()
693 {
694 string? ParentId = ServiceRef.TagProfile.PubSubJid;
695 if (string.IsNullOrWhiteSpace(ParentId))
696 return 0;
697
698 int Removed = await this.cacheManager.RemoveByParentId(ParentId);
699 string? Domain = ServiceRef.TagProfile.Domain;
700 if (!string.IsNullOrWhiteSpace(Domain))
701 await this.cacheManager.Remove(GetUnsupportedCacheKey(Domain));
702 return Removed;
703 }
704
705 private void Dispose(bool disposing)
706 {
707 if (!this.disposedValue)
708 {
709 if (disposing)
710 {
711 }
712 this.disposedValue = true;
713 }
714 }
718 public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); }
719 }
720}
XML Schemes (namespaces used also as schema registration keys) + packaged file names.
Definition: Constants.cs:112
A set of never changing property constants and helpful values.
Definition: Constants.cs:24
Base class that references services in the app.
Definition: ServiceRef.cs:43
static ILogService LogService
Log service.
Definition: ServiceRef.cs:214
static INetworkService NetworkService
Network service.
Definition: ServiceRef.cs:226
static ITagProfile TagProfile
TAG Profile service.
Definition: ServiceRef.cs:202
static IXmlSchemaValidationService XmlSchemaValidationService
XML schema validation service.
Definition: ServiceRef.cs:444
Manages application theming. Applies bundled (local) light/dark themes and, when a provider domain is...
Definition: ThemeService.cs:24
void SetLocalTheme(AppTheme Theme)
Applies only bundled local theme dictionaries for the selected theme (no remote branding fetch).
string GetImageUri(string Id)
Returns the absolute URI string for a branding image reference id, or empty string if not present.
void SetTheme(AppTheme Theme)
Sets the desired application theme and applies its local resource dictionary. If Unspecified,...
Definition: ThemeService.cs:81
IReadOnlyDictionary< string, Uri > ImageUris
Current mapping of branding image identifiers to absolute URIs (empty if no provider theme applied).
Definition: ThemeService.cs:64
Task< AppTheme > GetTheme()
Returns the current user application theme (may be AppTheme.Unspecified if unset).
async Task ApplyProviderThemeAsync()
Attempts to fetch and apply provider branding using the background refresh policy.
async Task< ThemeApplyOutcome > ApplyProviderThemeAsync(ThemeFetchPolicy Policy, CancellationToken CancellationToken=default)
Attempts to fetch and apply provider branding according to the specified policy.
async Task< int > ClearBrandingCacheForCurrentDomain()
Clears locally cached branding descriptors for the current provider domain.
void Dispose()
Disposes the service, releasing synchronization resources.
TaskCompletionSource ThemeLoaded
Completes when a provider theme application attempt (success, unsupported, or failure) finishes.
Definition: ThemeService.cs:74
ThemeService()
Initializes a new ThemeService instance with cache manager and synchronization primitives.
Definition: ThemeService.cs:55
Static class that gives access to app-specific themed colors. All colors are fetched directly from th...
Definition: AppColors.cs:8
static Color PrimaryBackground
Primary background color.
Definition: AppColors.cs:149
Service for loading, applying, and retrieving themes and branding in the application....
Definition: ImplTypes.g.cs:58
ThemeApplyOutcome
Represents the outcome of a provider theme application attempt.
ThemeFetchPolicy
Defines how provider branding should be fetched and applied.