Neuron®
The Neuron® is the basis for the creation of open and secure federated networks for smart societies.
Loading...
Searching...
No Matches
WelcomeOnboardingStepViewModel.cs
1using System;
4using System.Text;
5using System.Threading;
6using System.Threading.Tasks;
7using System.Xml;
8using CommunityToolkit.Mvvm.ComponentModel;
9using CommunityToolkit.Mvvm.Input;
10using Microsoft.Maui.ApplicationModel;
11using NeuroAccessMaui;
16using Waher.Content;
21
23{
28 {
29 private CancellationTokenSource? inviteCodeCts;
30 private const int InviteCodeDebounceMs = 500;
31 private bool autoAdvanced;
32
33 public WelcomeOnboardingStepViewModel() : base(OnboardingStep.Welcome) { }
34
38 [ObservableProperty]
39 [NotifyPropertyChangedFor(nameof(IsBusy))]
40 private bool isLoading = true;
41
45 [ObservableProperty]
46 private string? inviteCode;
47
51 [ObservableProperty]
52 private bool inviteCodeIsValid = true;
53
57 [ObservableProperty]
58 [NotifyPropertyChangedFor(nameof(IsBusy))]
59 private bool isProcessingInvite;
60
65
69 public override string NextButtonText => string.Empty; // Not used.
70
74 public bool CanContinue => false; // Manual button disabled.
75
79 public bool IsBusy => this.IsLoading || this.IsProcessingInvite;
80
81 internal override async Task OnActivatedAsync()
82 {
83 ServiceRef.LogService.LogInformational("Welcome step activated.");
84 await base.OnActivatedAsync();
85 if (this.IsLoading)
86 {
87 ServiceRef.LogService.LogDebug("Simulating initial loading delay.");
88 await Task.Delay(800).ConfigureAwait(false);
89 MainThread.BeginInvokeOnMainThread(() =>
90 {
91 this.IsLoading = false;
92 ServiceRef.LogService.LogInformational("Welcome step loading complete.");
93 });
94 }
95 }
96
97 internal override Task<bool> OnNextAsync() => Task.FromResult(true);
98
99 partial void OnInviteCodeChanged(string? value)
100 {
101 ServiceRef.LogService.LogDebug($"Invite code changed. RawValue='{value ?? string.Empty}' AutoAdvanced={this.autoAdvanced}.");
102 if (this.autoAdvanced)
103 {
104 ServiceRef.LogService.LogDebug("Change ignored: already auto-advanced.");
105 return;
106 }
107
108 this.CancelPendingInviteProcessing();
109
110 if (string.IsNullOrWhiteSpace(value))
111 {
112 this.InviteCodeIsValid = true;
113 ServiceRef.LogService.LogDebug("Invite code empty -> awaiting input.");
114 return;
115 }
116
117 bool IsValid = BasicValidateInviteCode(value, out string Trimmed);
118 this.InviteCodeIsValid = IsValid;
119 ServiceRef.LogService.LogInformational($"Invite code validation result: Valid={IsValid} Trimmed='{Trimmed}'.");
120 if (!IsValid)
121 {
122 ServiceRef.LogService.LogWarning("Invite code failed basic validation.");
123 return;
124 }
125
126 CancellationTokenSource DebounceCts = new CancellationTokenSource();
127 this.inviteCodeCts = DebounceCts;
128 ServiceRef.LogService.LogDebug($"Debounce scheduled ({InviteCodeDebounceMs} ms) for invitation processing.");
129
130 _ = Task.Run(async () =>
131 {
132 try
133 {
134 await Task.Delay(InviteCodeDebounceMs, DebounceCts.Token).ConfigureAwait(false);
135 if (DebounceCts.IsCancellationRequested)
136 {
137 ServiceRef.LogService.LogDebug("Debounce cancelled before execution.");
138 return;
139 }
140 if (!string.Equals(this.InviteCode, value, StringComparison.Ordinal))
141 {
142 ServiceRef.LogService.LogDebug("Invite code changed during debounce; aborting processing.");
143 return;
144 }
145 ServiceRef.LogService.LogInformational("Debounce elapsed. Processing invitation.");
146 this.SetInviteProcessingState(true);
147 try
148 {
149 bool Processed = await this.ProcessInvitationAsync(Trimmed).ConfigureAwait(false);
150 if (Processed)
151 {
152 MainThread.BeginInvokeOnMainThread(this.AdvanceAfterInvite);
153 }
154 }
155 finally
156 {
157 this.SetInviteProcessingState(false);
158 }
159 }
160 catch (OperationCanceledException)
161 {
162 ServiceRef.LogService.LogDebug("Debounce task cancelled via token.");
163 }
164 catch (Exception Ex)
165 {
166 ServiceRef.LogService.LogException(Ex);
167 }
168 finally
169 {
170 if (ReferenceEquals(this.inviteCodeCts, DebounceCts))
171 {
172 this.inviteCodeCts = null;
173 }
174
175 DebounceCts.Dispose();
176 }
177 }, DebounceCts.Token);
178 }
179
180 public async Task HandleInviteCodeFromIntent(string inviteUrl)
181 {
182 ServiceRef.LogService.LogInformational($"Handling invite code from intent: '{inviteUrl}'.");
183
184 bool IsValid = BasicValidateInviteCode(inviteUrl, out string Trimmed);
185 this.InviteCodeIsValid = IsValid;
186 ServiceRef.LogService.LogInformational($"Invite code validation result: Valid={IsValid} Trimmed='{Trimmed}'.");
187
188 if (!IsValid)
189 {
190 ServiceRef.LogService.LogWarning("Invite code failed basic validation.");
191 return;
192 }
193
194 try
195 {
196 bool Processed = await this.ProcessInvitationAsync(Trimmed).ConfigureAwait(false);
197 if (Processed)
198 {
199 MainThread.BeginInvokeOnMainThread(this.AdvanceAfterInvite);
200 }
201 }
202 finally
203 {
204 this.SetInviteProcessingState(false);
205 }
206 }
207
208 private void AdvanceAfterInvite()
209 {
210 if (this.autoAdvanced)
211 {
212 ServiceRef.LogService.LogDebug("AdvanceAfterInvite ignored: already advanced.");
213 return;
214 }
215 this.autoAdvanced = true;
216 ServiceRef.LogService.LogInformational("Advancing to next onboarding step after invitation processing.");
217 if (this.CoordinatorViewModel is not null)
218 this.CoordinatorViewModel.GoToNextCommand.Execute(null);
219 }
220
221 [RelayCommand]
222 private async Task PasteInviteCode()
223 {
224 ServiceRef.LogService.LogDebug("PasteInviteCode invoked.");
225 if (!Clipboard.HasText)
226 {
227 ServiceRef.LogService.LogWarning("Clipboard has no text to paste.");
228 return;
229 }
230 string? ClipboardText = await Clipboard.GetTextAsync();
231 if (string.IsNullOrWhiteSpace(ClipboardText))
232 {
233 ServiceRef.LogService.LogWarning("Clipboard text is empty or whitespace.");
234 return;
235 }
236 ServiceRef.LogService.LogInformational("Invite code pasted from clipboard.");
237 await Clipboard.SetTextAsync(null);
238 this.InviteCode = ClipboardText;
239 }
240
241 [RelayCommand]
242 private async Task ScanQrCode()
243 {
244 ServiceRef.LogService.LogDebug("ScanQrCode command invoked.");
245 string? Url = await QrCode.ScanQrCode(nameof(AppResources.ScanQRCode), [Constants.UriSchemes.Onboarding]).ConfigureAwait(false);
246 if (string.IsNullOrWhiteSpace(Url))
247 {
248 ServiceRef.LogService.LogWarning("QR scan returned empty URL.");
249 return;
250 }
251 string? Scheme = Constants.UriSchemes.GetScheme(Url);
252 if (!string.Equals(Scheme, Constants.UriSchemes.Onboarding, StringComparison.OrdinalIgnoreCase))
253 {
254 ServiceRef.LogService.LogWarning($"QR scan scheme mismatch: '{Scheme}'.");
255 return;
256 }
257 ServiceRef.LogService.LogInformational("Valid onboarding QR code scanned.");
258 MainThread.BeginInvokeOnMainThread(() => this.InviteCode = Url);
259 }
260
261 [RelayCommand]
262 private async Task SelectForMe()
263 {
264 ServiceRef.LogService.LogInformational("SelectForMe command invoked from Welcome step.");
265
266 this.CancelPendingInviteProcessing();
267
268 if (!await this.PrepareAutomaticProviderSelectionAsync().ConfigureAwait(false))
269 {
270 ServiceRef.LogService.LogWarning("Automatic provider preparation failed. Staying on Welcome step.");
271 return;
272 }
273
274 bool NavigationSucceeded = false;
275 try
276 {
277 this.autoAdvanced = true;
278
279 await MainThread.InvokeOnMainThreadAsync(() => this.InviteCode = null);
280
281 if (this.CoordinatorViewModel is null)
282 {
283 ServiceRef.LogService.LogWarning("Coordinator not attached; cannot advance to ValidatePhone.");
284 return;
285 }
286
287 ServiceRef.LogService.LogInformational("Advancing directly to ValidatePhone step after automatic provider selection.");
288 await this.CoordinatorViewModel.GoToStepCommand.ExecuteAsync(OnboardingStep.ValidatePhone).ConfigureAwait(false);
289 NavigationSucceeded = true;
290 }
291 finally
292 {
293 if (!NavigationSucceeded)
294 {
295 this.autoAdvanced = false;
296 }
297 }
298 }
299
300 private async Task<bool> PrepareAutomaticProviderSelectionAsync()
301 {
302 try
303 {
304 bool HadExistingDomain = !string.IsNullOrEmpty(ServiceRef.TagProfile.Domain) ||
305 !string.IsNullOrEmpty(ServiceRef.TagProfile.ApiKey) ||
306 !string.IsNullOrEmpty(ServiceRef.TagProfile.ApiSecret);
307
308 if (HadExistingDomain)
309 {
310 ServiceRef.LogService.LogInformational("Clearing previously selected domain to enable automatic provider selection.");
311 }
312 else
313 {
314 ServiceRef.LogService.LogInformational("No domain selected; ready for automatic provider selection.");
315 }
316
317 ServiceRef.TagProfile.ClearDomain();
318 return true;
319 }
320 catch (Exception Ex)
321 {
322 ServiceRef.LogService.LogException(Ex);
323 await MainThread.InvokeOnMainThreadAsync(async () =>
324 {
325 await ServiceRef.UiService.DisplayAlert(
329 });
330
331 return false;
332 }
333 }
334
335 internal override Task OnBackAsync()
336 {
337 ServiceRef.LogService.LogDebug("Back requested from Welcome step.");
338 this.CancelPendingInviteProcessing();
339 return Task.CompletedTask;
340 }
341
342 private void SetInviteProcessingState(bool isProcessing)
343 {
344 if (MainThread.IsMainThread)
345 {
346 this.IsProcessingInvite = isProcessing;
347 }
348 else
349 {
350 MainThread.BeginInvokeOnMainThread(() => this.IsProcessingInvite = isProcessing);
351 }
352 }
353
354 private void CancelPendingInviteProcessing()
355 {
356 CancellationTokenSource? existing = Interlocked.Exchange(ref this.inviteCodeCts, null);
357 if (existing is null)
358 {
359 return;
360 }
361
362 try
363 {
364 existing.Cancel();
365 }
366 catch (ObjectDisposedException)
367 {
368 // Already disposed.
369 }
370 }
371
372 private void MarkInviteAsInvalid(string messageResourceKey)
373 {
374 MainThread.BeginInvokeOnMainThread(async () =>
375 {
376 this.InviteCodeIsValid = false;
377 await ServiceRef.UiService.DisplayAlert(
379 ServiceRef.Localizer[messageResourceKey],
381 });
382 }
383
384 private static bool BasicValidateInviteCode(string code, out string trimmed)
385 {
386 trimmed = code.Trim();
387 if (string.IsNullOrEmpty(trimmed))
388 return true; // Allow empty while typing
389 if (!trimmed.StartsWith(Constants.UriSchemes.Onboarding + ":", StringComparison.OrdinalIgnoreCase))
390 return false;
391 string[] Parts = trimmed.Split(':', StringSplitOptions.RemoveEmptyEntries);
392 if (Parts.Length != 5)
393 return false;
394 string Domain = Parts[1];
395 string CodePart = Parts[2];
396 string KeyPart = Parts[3];
397 string IvPart = Parts[4];
398 if (string.IsNullOrWhiteSpace(Domain) || Domain.Contains(' '))
399 return false;
400 if (string.IsNullOrWhiteSpace(CodePart))
401 return false;
402 try
403 {
404 _ = Convert.FromBase64String(KeyPart);
405 _ = Convert.FromBase64String(IvPart);
406 }
407 catch { return false; }
408 return true;
409 }
410
411 private async Task<bool> ProcessInvitationAsync(string inviteUrl)
412 {
413 ServiceRef.LogService.LogInformational($"Processing invitation: '{inviteUrl}'.");
414 string[] Parts = inviteUrl.Split(':', StringSplitOptions.RemoveEmptyEntries);
415 if (Parts.Length != 5)
416 {
417 ServiceRef.LogService.LogWarning("Invitation split length invalid.");
418 this.MarkInviteAsInvalid(nameof(AppResources.InvalidInvitationCode));
419 return false;
420 }
421 string Domain = Parts[1];
422 string Code = Parts[2];
423 string KeyStr = Parts[3];
424 string IvStr = Parts[4];
425 string EncryptedStr;
426 Uri DomainInfoUri;
427 try
428 {
429 DomainInfoUri = new Uri("https://" + Domain + "/Onboarding/GetInfo");
430 }
431 catch (Exception Ex)
432 {
433 ServiceRef.LogService.LogException(Ex);
434 this.MarkInviteAsInvalid(nameof(AppResources.InvalidInvitationCode));
435 return false;
436 }
437 try
438 {
439 ServiceRef.LogService.LogDebug("Posting invite code to domain info endpoint.");
441 DomainInfoUri,
442 Encoding.ASCII.GetBytes(Code),
443 "text/plain",
444 null,
446 new KeyValuePair<string, string>("Accept", "text/plain"));
447 Response.AssertOk();
448 ContentResponse Decoded = await InternetContent.DecodeAsync(Response.ContentType, Response.Encoded, DomainInfoUri);
449 Decoded.AssertOk();
450 EncryptedStr = (string)Decoded.Decoded;
451 ServiceRef.LogService.LogInformational("Invitation payload retrieved successfully.");
452 }
453 catch (Exception Ex)
454 {
455 ServiceRef.LogService.LogException(Ex);
456 this.MarkInviteAsInvalid(nameof(AppResources.UnableToAccessInvitation));
457 return false;
458 }
459
460 XmlElement? LegalIdDefinition = null;
461 string? transferredAccount = null;
462 string? transferredPassword = null;
463 string? transferredPasswordMethod = null;
464 bool domainSelectedFromInvite = false;
465 try
466 {
467 ServiceRef.LogService.LogDebug("Decrypting invitation payload.");
468 byte[] Key = Convert.FromBase64String(KeyStr);
469 byte[] Iv = Convert.FromBase64String(IvStr);
470 byte[] Encrypted = Convert.FromBase64String(EncryptedStr);
471 using Aes Aes = Aes.Create();
472 Aes.BlockSize = 128;
473 Aes.KeySize = 256;
474 Aes.Mode = CipherMode.CBC;
475 Aes.Padding = PaddingMode.PKCS7;
476 using ICryptoTransform Decryptor = Aes.CreateDecryptor(Key, Iv);
477 byte[] Decrypted = Decryptor.TransformFinalBlock(Encrypted, 0, Encrypted.Length);
478 string XmlStr = Encoding.UTF8.GetString(Decrypted);
479 XmlDocument Doc = new XmlDocument { PreserveWhitespace = true };
480 Doc.LoadXml(XmlStr);
481 if (Doc.DocumentElement is null || Doc.DocumentElement.NamespaceURI != ContractsClient.NamespaceOnboarding)
482 throw new Exception("Invalid Invitation XML");
483 ServiceRef.LogService.LogInformational("Invitation XML parsed.");
484 LinkedList<XmlElement> ToProcess = new LinkedList<XmlElement>();
485 ToProcess.AddLast(Doc.DocumentElement);
486 while (ToProcess.First is not null)
487 {
488 XmlElement E = ToProcess.First.Value;
489 ToProcess.RemoveFirst();
490 ServiceRef.LogService.LogDebug($"Processing XML node '{E.LocalName}'.");
491 switch (E.LocalName)
492 {
493 case "ApiKey":
494 string ApiKey = XML.Attribute(E, "key");
495 string Secret = XML.Attribute(E, "secret");
496 string ApiDomain = XML.Attribute(E, "domain");
497 if(string.IsNullOrEmpty(ApiDomain))
498 {
499 ApiDomain = await ServiceRef.UiService.DisplayPrompt("Please enter the IP of your provider", "This is for developers and testers", "Enter", "Cancel");
500 }
501 ServiceRef.LogService.LogInformational($"Selecting domain (ApiKey) '{ApiDomain}'.");
502 await SelectDomain(ApiDomain, ApiKey, Secret).ConfigureAwait(false);
503 domainSelectedFromInvite = true;
504 break;
505 case "Account":
506 string UserName = XML.Attribute(E, "userName");
507 string Password = XML.Attribute(E, "password");
508 string PasswordMethod = XML.Attribute(E, "passwordMethod");
509 string AccDomain = XML.Attribute(E, "domain");
510 ServiceRef.LogService.LogInformational($"Selecting domain (Account) '{AccDomain}'. Capturing transfer for user '{UserName}'.");
511 await SelectDomain(AccDomain, string.Empty, string.Empty).ConfigureAwait(false);
512 transferredAccount = UserName;
513 transferredPassword = Password;
514 transferredPasswordMethod = PasswordMethod;
515 break;
516 case "LegalId":
517 LegalIdDefinition = E;
518 ServiceRef.LogService.LogInformational("LegalId definition captured.");
519 break;
520 case "Transfer":
521 foreach (XmlNode N in E.ChildNodes)
522 if (N is XmlElement E2)
523 ToProcess.AddLast(E2);
524 ServiceRef.LogService.LogDebug("Transfer node expanded.");
525 break;
526 default:
527 ServiceRef.LogService.LogWarning($"Unexpected XML node '<{E.LocalName}>'.");
528 break;
529 }
530 }
531
532 bool advancedManually = false;
533 bool shouldAdvance = false;
534 if (!string.IsNullOrEmpty(transferredAccount) && transferredPassword is not null && transferredPasswordMethod is not null && this.CoordinatorViewModel is not null)
535 {
536 OnboardingTransferContext Context = new OnboardingTransferContext(transferredAccount, transferredPassword, transferredPasswordMethod, LegalIdDefinition);
537 await this.CoordinatorViewModel.ApplyTransferContextAsync(Context).ConfigureAwait(false);
538 shouldAdvance = true;
539
540 bool Connected = await this.CoordinatorViewModel.TryFinalizeTransferAsync(false).ConfigureAwait(false);
541 ServiceRef.LogService.LogInformational("Deferred transfer connection attempt finished.",
542 new KeyValuePair<string, object?>("Success", Connected),
543 new KeyValuePair<string, object?>("HasLegalId", Context.HasLegalIdentity));
544 if (Connected && Context.HasLegalIdentity)
545 {
546 advancedManually = true;
547 MainThread.BeginInvokeOnMainThread(async () =>
548 {
549 await this.CoordinatorViewModel.GoToStepCommand.ExecuteAsync(OnboardingStep.DefinePassword);
550 });
551 }
552 }
553 else
554 {
555 if (domainSelectedFromInvite)
556 {
557 shouldAdvance = true;
558 }
559 else
560 {
561 this.MarkInviteAsInvalid(nameof(AppResources.InvalidInvitationCode));
562 return false;
563 }
564 }
565
566 if (!advancedManually)
567 {
568 return shouldAdvance;
569 }
570
571 return false;
572 }
573 catch (Exception Ex)
574 {
575 ServiceRef.LogService.LogException(Ex);
576 this.MarkInviteAsInvalid(nameof(AppResources.InvalidInvitationCode));
577 }
578
579 return false;
580 }
581
582 private static async Task SelectDomain(string Domain, string Key, string Secret)
583 {
584 ServiceRef.LogService.LogDebug($"SelectDomain called for '{Domain}'.");
585 bool DefaultConnectivity;
586 try
587 {
588 (string HostName, int PortNumber, bool _) = await ServiceRef.NetworkService.LookupXmppHostnameAndPort(Domain);
589 DefaultConnectivity = HostName == Domain && PortNumber == XmppCredentials.DefaultPort;
590 ServiceRef.LogService.LogInformational($"Domain resolution: Host='{HostName}' Port={PortNumber} DefaultConnectivity={DefaultConnectivity}.");
591 }
592 catch (Exception Ex)
593 {
594 ServiceRef.LogService.LogException(Ex);
595 DefaultConnectivity = false;
596 }
597 ServiceRef.TagProfile.SetDomain(Domain, DefaultConnectivity, Key, Secret);
598 ServiceRef.LogService.LogInformational("Domain selection stored in TagProfile.");
599 }
600
601 }
602}
Represents an instance of the Neuro-Access app.
Definition: App.xaml.cs:125
static void ValidateCertificateCallback(object? Sender, RemoteCertificateEventArgs Args)
Callback for validating SSL certificates. This method is called when a remote certificate is received...
Definition: App.xaml.cs:1135
const string Onboarding
Onboarding URI Scheme (obinfo)
Definition: Constants.cs:183
static ? string GetScheme(string Url)
Gets the predefined scheme from an IoT Code
Definition: Constants.cs:200
A set of never changing property constants and helpful values.
Definition: Constants.cs:24
A strongly-typed resource class, for looking up localized strings, etc.
static string UnableToAccessInvitation
Looks up a localized string similar to Unable to access invitation information..
static string ActivateYourDigitalIdentity
Looks up a localized string similar to Select your ID provider.
static string ScanQRCode
Looks up a localized string similar to Scan QR Code.
static string InvalidInvitationCode
Looks up a localized string similar to Invalid invitation code..
static string Ok
Looks up a localized string similar to OK.
static string SomethingWentWrong
Looks up a localized string similar to Something went wrong.
static string ErrorTitle
Looks up a localized string similar to An error has occurred.
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 IUiService UiService
Service serializing and managing UI-related tasks.
Definition: ServiceRef.cs:130
static ITagProfile TagProfile
TAG Profile service.
Definition: ServiceRef.cs:202
static IReportingStringLocalizer Localizer
Localization service
Definition: ServiceRef.cs:370
Helper class to perform scanning of QR Codes by displaying the UI and handling async results.
Definition: QrCode.cs:20
static async Task< string?> ScanQrCode(string? QrTitle, string[] AllowedSchemas)
Navigates to the Scan QR Code Page, waits for scan to complete, and returns the result....
Definition: QrCode.cs:191
Provides state and logic for the welcome onboarding step, including invite and QR handling.
override string NextButtonText
Gets the text displayed on the (unused) next button.
bool CanContinue
Gets a value indicating whether manual continuation is allowed.
bool IsBusy
Gets a value indicating whether the welcome step is initializing or processing invite data.
override string Title
Gets the localized title displayed on the welcome step.
Contains information about a binary response to a content request.
string ContentType
Internet Content-Type of encoded object.
void AssertOk()
Asserts response is OK.
Contains information about a response to a content request.
object Decoded
Decoded object.
void AssertOk()
Asserts response is OK.
Static class managing encoding and decoding of internet content.
static Task< ContentResponse > PostAsync(Uri Uri, object Data, params KeyValuePair< string, string >[] Headers)
Posts to a resource, using a Uniform Resource Identifier (or Locator).
static Task< ContentResponse > DecodeAsync(string ContentType, byte[] Data, Encoding Encoding, KeyValuePair< string, string >[] Fields, Uri BaseUri)
Decodes an object.
Helps with common XML-related tasks.
Definition: XML.cs:21
static string Attribute(XmlElement E, string Name)
Gets the value of an XML attribute.
Definition: XML.cs:1062
Adds support for legal identities, smart contracts and signatures to an XMPP client.
const string NamespaceOnboarding
http://waher.se/schema/Onboarding/v1.xsd
Class containing credentials for an XMPP client connection.
const int DefaultPort
Default XMPP Server port.
Definition: ImplTypes.g.cs:58
OnboardingStep
Unified onboarding steps matching the registration journey.