]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
login form: use const constructor to improve performance
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
1 import 'dart:io';
2 import 'dart:async';
3
4 import 'package:flutter/material.dart';
5 import 'package:collection/collection.dart';
6 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
7 as proxclient;
8 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
9 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
11 import 'package:proxmox_login_manager/proxmox_login_model.dart';
12 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
13 import 'package:proxmox_login_manager/extension.dart';
14 import 'package:proxmox_login_manager/proxmox_password_store.dart';
15
16 class ProxmoxProgressModel {
17 int inProgress = 0;
18 String message = 'Loading...';
19 ProxmoxProgressModel({
20 this.inProgress = 0,
21 this.message = 'Loading...',
22 });
23 }
24
25 // FIXME: copied from pve_flutter_frontend, re-use common set
26 class ProxmoxColors {
27 static const Color orange = Color(0xFFE57000);
28 static const Color supportGrey = Color(0xFFABBABA);
29 static const Color supportBlue = Color(0xFF00617F);
30 }
31
32 class ProxmoxLoginForm extends StatefulWidget {
33 final TextEditingController originController;
34 final FormFieldValidator<String> originValidator;
35 final TextEditingController usernameController;
36 final TextEditingController passwordController;
37 final List<PveAccessDomainModel?>? accessDomains;
38 final PveAccessDomainModel? selectedDomain;
39 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
40 final Function? onPasswordSubmitted;
41 final Function onOriginSubmitted;
42 final Function? onSavePasswordChanged;
43 final bool? canSavePassword;
44 final bool? passwordSaved;
45
46 const ProxmoxLoginForm({
47 super.key,
48 required this.originController,
49 required this.usernameController,
50 required this.passwordController,
51 required this.accessDomains,
52 required this.originValidator,
53 this.selectedDomain,
54 required this.onDomainChanged,
55 this.onPasswordSubmitted,
56 required this.onOriginSubmitted,
57 this.onSavePasswordChanged,
58 this.canSavePassword,
59 this.passwordSaved,
60 });
61
62 @override
63 State<ProxmoxLoginForm> createState() => _ProxmoxLoginFormState();
64 }
65
66 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
67 bool _obscure = true;
68 bool? _savePwCheckbox;
69 FocusNode? passwordFocusNode;
70
71 @override
72 Widget build(BuildContext context) {
73 if (widget.accessDomains == null) {
74 return TextFormField(
75 decoration: const InputDecoration(
76 icon: Icon(Icons.vpn_lock),
77 labelText: 'Origin',
78 hintText: 'e.g. 192.168.1.2',
79 helperText:
80 'Protocol (https) and default port (8006 or 443) implied'),
81 textInputAction: TextInputAction.next,
82 controller: widget.originController,
83 validator: widget.originValidator,
84 onFieldSubmitted: (value) => widget.onOriginSubmitted(),
85 );
86 }
87
88 return AutofillGroup(
89 child: Column(
90 mainAxisAlignment: MainAxisAlignment.center,
91 children: [
92 TextFormField(
93 decoration: const InputDecoration(
94 icon: Icon(Icons.vpn_lock),
95 labelText: 'Origin',
96 ),
97 controller: widget.originController,
98 enabled: false,
99 ),
100 TextFormField(
101 decoration: const InputDecoration(
102 icon: Icon(Icons.person),
103 labelText: 'Username',
104 ),
105 controller: widget.usernameController,
106 validator: (value) {
107 if (value!.isEmpty) {
108 return 'Please enter username';
109 }
110 return null;
111 },
112 autofillHints: const [AutofillHints.username],
113 ),
114 DropdownButtonFormField(
115 decoration: const InputDecoration(icon: Icon(Icons.domain)),
116 items: widget.accessDomains!
117 .map((e) => DropdownMenuItem(
118 value: e,
119 child: ListTile(
120 title: Text(e!.realm),
121 subtitle: Text(e.comment ?? ''),
122 ),
123 ))
124 .toList(),
125 onChanged: widget.onDomainChanged,
126 selectedItemBuilder: (context) =>
127 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
128 value: widget.selectedDomain,
129 ),
130 Stack(
131 children: [
132 TextFormField(
133 decoration: const InputDecoration(
134 icon: Icon(Icons.lock),
135 labelText: 'Password',
136 ),
137 controller: widget.passwordController,
138 obscureText: _obscure,
139 autocorrect: false,
140 focusNode: passwordFocusNode,
141 validator: (value) {
142 if (value!.isEmpty) {
143 return 'Please enter password';
144 }
145 return null;
146 },
147 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
148 autofillHints: const [AutofillHints.password],
149 ),
150 Align(
151 alignment: Alignment.bottomRight,
152 child: IconButton(
153 constraints: BoxConstraints.tight(const Size(58, 58)),
154 iconSize: 24,
155 icon:
156 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
157 onPressed: () => setState(() {
158 _obscure = !_obscure;
159 }),
160 ),
161 )
162 ],
163 ),
164 if (widget.canSavePassword ?? false)
165 CheckboxListTile(
166 title: const Text('Save password'),
167 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
168 onChanged: (value) {
169 if (widget.onSavePasswordChanged != null) {
170 widget.onSavePasswordChanged!(value!);
171 }
172 setState(() {
173 _savePwCheckbox = value!;
174 });
175 },
176 )
177 ],
178 ),
179 );
180 }
181
182 @override
183 void dispose() {
184 passwordFocusNode?.dispose();
185 super.dispose();
186 }
187 }
188
189 class ProxmoxLoginPage extends StatefulWidget {
190 final ProxmoxLoginModel? userModel;
191 final bool? isCreate;
192 final String? ticket;
193 final String? password;
194
195 const ProxmoxLoginPage({
196 super.key,
197 this.userModel,
198 this.isCreate,
199 this.ticket = '',
200 this.password,
201 });
202 @override
203 State<ProxmoxLoginPage> createState() => _ProxmoxLoginPageState();
204 }
205
206 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
207 final _originController = TextEditingController();
208 final _usernameController = TextEditingController();
209 final _passwordController = TextEditingController();
210 Future<List<PveAccessDomainModel?>?>? _accessDomains;
211 PveAccessDomainModel? _selectedDomain;
212 final _formKey = GlobalKey<FormState>();
213 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
214 bool _submittButtonEnabled = true;
215 bool _canSavePassword = false;
216 bool _savePasswordCB = false;
217
218 @override
219 void initState() {
220 super.initState();
221 final userModel = widget.userModel;
222 _progressModel = ProxmoxProgressModel();
223 if (!widget.isCreate! && userModel != null) {
224 _originController.text = userModel.origin?.toString() ?? '';
225 // Uri does not append 443 for https, so we do it manually
226 if (userModel.origin != null &&
227 userModel.origin!.scheme == "https" &&
228 userModel.origin!.port == 443) {
229 _originController.text += ":443";
230 }
231 _passwordController.text = widget.password ?? '';
232 _accessDomains = _getAccessDomains();
233 _usernameController.text = userModel.username!;
234 _savePasswordCB = widget.password != null;
235 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
236 widget.password != null) {
237 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
238 }
239 }
240 }
241
242 @override
243 Widget build(BuildContext context) {
244 return Theme(
245 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
246 data: ThemeData.dark().copyWith(
247 textButtonTheme: TextButtonThemeData(
248 style: TextButton.styleFrom(
249 foregroundColor: Colors.white,
250 backgroundColor: ProxmoxColors.orange,
251 disabledBackgroundColor: Colors.grey,
252 ),
253 ),
254 colorScheme: const ColorScheme.dark().copyWith(
255 primary: ProxmoxColors.orange,
256 secondary: ProxmoxColors.orange,
257 onSecondary: ProxmoxColors.supportGrey),
258 checkboxTheme: CheckboxThemeData(
259 fillColor: MaterialStateProperty.resolveWith<Color?>(
260 (Set<MaterialState> states) {
261 if (states.contains(MaterialState.disabled)) {
262 return null;
263 }
264 if (states.contains(MaterialState.selected)) {
265 return ProxmoxColors.orange;
266 }
267 return null;
268 }),
269 ),
270 radioTheme: RadioThemeData(
271 fillColor: MaterialStateProperty.resolveWith<Color?>(
272 (Set<MaterialState> states) {
273 if (states.contains(MaterialState.disabled)) {
274 return null;
275 }
276 if (states.contains(MaterialState.selected)) {
277 return ProxmoxColors.orange;
278 }
279 return null;
280 }),
281 ),
282 switchTheme: SwitchThemeData(
283 thumbColor: MaterialStateProperty.resolveWith<Color?>(
284 (Set<MaterialState> states) {
285 if (states.contains(MaterialState.disabled)) {
286 return null;
287 }
288 if (states.contains(MaterialState.selected)) {
289 return ProxmoxColors.orange;
290 }
291 return null;
292 }),
293 trackColor: MaterialStateProperty.resolveWith<Color?>(
294 (Set<MaterialState> states) {
295 if (states.contains(MaterialState.disabled)) {
296 return null;
297 }
298 if (states.contains(MaterialState.selected)) {
299 return ProxmoxColors.orange;
300 }
301 return null;
302 }),
303 ),
304 ),
305 child: Scaffold(
306 backgroundColor: ProxmoxColors.supportBlue,
307 extendBodyBehindAppBar: true,
308 appBar: AppBar(
309 elevation: 0.0,
310 backgroundColor: Colors.transparent,
311 leading: IconButton(
312 icon: const Icon(Icons.close),
313 onPressed: () => Navigator.of(context).pop(),
314 ),
315 ),
316 body: Stack(
317 children: [
318 SingleChildScrollView(
319 child: ConstrainedBox(
320 constraints: BoxConstraints.tightFor(
321 height: MediaQuery.of(context).size.height),
322 child: SafeArea(
323 child: Padding(
324 padding: const EdgeInsets.all(8.0),
325 child: FutureBuilder<List<PveAccessDomainModel?>?>(
326 future: _accessDomains,
327 builder: (context, snapshot) {
328 return Form(
329 key: _formKey,
330 onChanged: () {
331 setState(() {
332 _submittButtonEnabled =
333 _formKey.currentState!.validate();
334 });
335 },
336 child: Column(
337 mainAxisAlignment: MainAxisAlignment.center,
338 children: [
339 Expanded(
340 child: Column(
341 mainAxisAlignment: MainAxisAlignment.center,
342 children: [
343 Image.asset(
344 'assets/images/proxmox_logo_symbol_wordmark.png',
345 package: 'proxmox_login_manager',
346 ),
347 ],
348 ),
349 ),
350 ProxmoxLoginForm(
351 originController: _originController,
352 originValidator: (value) {
353 if (value == null || value.isEmpty) {
354 return 'Please enter origin';
355 }
356 try {
357 normalizeUrl(value);
358 return null;
359 } on FormatException catch (_) {
360 return 'Invalid URI';
361 } on Exception catch (e) {
362 return 'Invalid URI: $e';
363 }
364 },
365 usernameController: _usernameController,
366 passwordController: _passwordController,
367 accessDomains: snapshot.data,
368 selectedDomain: _selectedDomain,
369 onSavePasswordChanged: (value) {
370 _savePasswordCB = value;
371 },
372 canSavePassword: _canSavePassword,
373 passwordSaved: widget.password != null,
374 onDomainChanged: (value) {
375 setState(() {
376 _selectedDomain = value;
377 });
378 },
379 onOriginSubmitted: () {
380 final isValid =
381 _formKey.currentState!.validate();
382 setState(() {
383 _submittButtonEnabled = isValid;
384 });
385 if (isValid) {
386 setState(() {
387 _accessDomains = _getAccessDomains();
388 });
389 }
390 },
391 onPasswordSubmitted: _submittButtonEnabled
392 ? () {
393 final isValid =
394 _formKey.currentState!.validate();
395 setState(() {
396 _submittButtonEnabled = isValid;
397 });
398 if (isValid) {
399 _onLoginButtonPressed();
400 }
401 }
402 : null,
403 ),
404 Expanded(
405 child: Align(
406 alignment: Alignment.bottomCenter,
407 child: SizedBox(
408 width: MediaQuery.of(context).size.width,
409 child: TextButton(
410 onPressed: _submittButtonEnabled
411 ? () {
412 final isValid = _formKey
413 .currentState!
414 .validate();
415 setState(() {
416 _submittButtonEnabled =
417 isValid;
418 });
419 if (isValid) {
420 if (snapshot.hasData) {
421 _onLoginButtonPressed();
422 } else {
423 setState(() {
424 _accessDomains =
425 _getAccessDomains();
426 });
427 }
428 }
429 }
430 : null,
431 child: const Text('Continue'),
432 ),
433 ),
434 ),
435 ),
436 ],
437 ),
438 );
439 }),
440 ),
441 ),
442 ),
443 ),
444 if (_progressModel.inProgress > 0)
445 ProxmoxProgressOverlay(message: _progressModel.message),
446 ],
447 ),
448 ),
449 );
450 }
451
452 Future<void> _onLoginButtonPressed(
453 {String ticket = '', String? mRealm}) async {
454 setState(() {
455 _progressModel
456 ..inProgress += 1
457 ..message = 'Authenticating...';
458 });
459
460 try {
461 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
462 //cleaned form fields
463 final origin = normalizeUrl(_originController.text.trim());
464 final username = _usernameController.text.trim();
465 final String enteredPassword = _passwordController.text.trim();
466 final String? savedPassword = widget.password;
467
468 final password = ticket.isNotEmpty ? ticket : enteredPassword;
469 final realm = _selectedDomain?.realm ?? mRealm;
470
471 var client = await proxclient.authenticate(
472 '$username@$realm', password, origin, settings.sslValidation!);
473
474 if (client.credentials.tfa != null &&
475 client.credentials.tfa!.kinds().isNotEmpty) {
476 if (!mounted) return;
477 ProxmoxApiClient? tfaclient =
478 await Navigator.of(context).push(MaterialPageRoute(
479 builder: (context) => ProxmoxTfaForm(
480 apiClient: client,
481 ),
482 ));
483
484 if (tfaclient != null) {
485 client = tfaclient;
486 } else {
487 setState(() {
488 _progressModel.inProgress -= 1;
489 });
490 return;
491 }
492 }
493
494 final status = await client.getClusterStatus();
495 final hostname =
496 status.singleWhereOrNull((element) => element.local ?? false)?.name;
497 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
498
499 final savePW = enteredPassword != '' &&
500 _savePasswordCB &&
501 enteredPassword != savedPassword;
502 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
503 String? id;
504
505 if (widget.isCreate!) {
506 final newLogin = ProxmoxLoginModel((b) => b
507 ..origin = origin
508 ..username = username
509 ..realm = realm
510 ..productType = ProxmoxProductType.pve
511 ..ticket = client.credentials.ticket
512 ..passwordSaved = savePW
513 ..hostname = hostname);
514
515 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
516 id = newLogin.identifier;
517 } else {
518 loginStorage = loginStorage!.rebuild((b) => b
519 ..logins.rebuildWhere(
520 (m) => m == widget.userModel,
521 (b) => b
522 ..ticket = client.credentials.ticket
523 ..passwordSaved =
524 savePW || (deletePW ? false : b.passwordSaved ?? false)
525 ..hostname = hostname));
526 id = widget.userModel!.identifier;
527 }
528
529 if (id != null) {
530 try {
531 if (savePW) {
532 await savePassword(id, enteredPassword);
533 } else if (deletePW) {
534 await deletePassword(id);
535 }
536 } catch (e) {
537 await showDialog(
538 context: context,
539 builder: (context) => AlertDialog(
540 title: const Text('Password saving error'),
541 scrollable: true,
542 content: Column(
543 mainAxisSize: MainAxisSize.min,
544 children: [
545 const Text('Could not save or delete password.'),
546 ExpansionTile(
547 title: const Text('Details'),
548 children: [Text(e.toString())],
549 )
550 ],
551 ),
552 actions: [
553 TextButton(
554 onPressed: () => Navigator.of(context).pop(),
555 child: const Text('Continue')),
556 ],
557 ));
558 }
559 }
560 await loginStorage.saveToDisk();
561
562 if (mounted) {
563 Navigator.of(context).pop(client);
564 }
565 } on proxclient.ProxmoxApiException catch (e) {
566 print(e);
567 if (!mounted) return;
568 if (e.message.contains('No ticket')) {
569 showDialog(
570 context: context,
571 builder: (context) => AlertDialog(
572 title: const Text('Version Error'),
573 content: const Text(
574 'Proxmox VE version not supported, please update your instance to use this app.'),
575 actions: [
576 TextButton(
577 onPressed: () => Navigator.of(context).pop(),
578 child: const Text('Close'),
579 ),
580 ],
581 ),
582 );
583 } else {
584 showDialog(
585 context: context,
586 builder: (context) => ProxmoxApiErrorDialog(
587 exception: e,
588 ),
589 );
590 }
591 } catch (e, trace) {
592 print(e);
593 print(trace);
594 if (mounted) {
595 if (e.runtimeType == HandshakeException) {
596 showDialog(
597 context: context,
598 builder: (context) => const ProxmoxCertificateErrorDialog(),
599 );
600 } else {
601 showDialog(
602 context: context,
603 builder: (context) => ConnectionErrorDialog(exception: e),
604 );
605 }
606 }
607 }
608 setState(() {
609 _progressModel.inProgress -= 1;
610 });
611 }
612
613 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
614 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
615 List<PveAccessDomainModel?>? response;
616 try {
617 response = await proxclient.accessDomains(uri, settings.sslValidation!);
618 } on proxclient.ProxmoxApiException catch (e) {
619 if (mounted) {
620 showDialog(
621 context: context,
622 builder: (context) => ProxmoxApiErrorDialog(
623 exception: e,
624 ),
625 );
626 }
627 } on HandshakeException {
628 if (mounted) {
629 showDialog(
630 context: context,
631 builder: (context) => const ProxmoxCertificateErrorDialog(),
632 );
633 }
634 }
635 return response;
636 }
637
638 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
639 List<PveAccessDomainModel?>? response;
640 try {
641 response = await _loadAccessDomains(uri);
642 } catch (e) {
643 if (mounted) {
644 showDialog(
645 context: context,
646 builder: (context) => ConnectionErrorDialog(
647 exception: e,
648 ),
649 );
650 }
651 }
652 return response;
653 }
654
655 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
656 setState(() {
657 _progressModel
658 ..inProgress += 1
659 ..message = 'Connecting...';
660 });
661
662 final canSavePW = await canSavePassword();
663 setState(() {
664 _canSavePassword = canSavePW;
665 });
666
667 var host = _originController.text.trim();
668 var apiBaseUrl = normalizeUrl(host);
669
670 RegExp portRE = RegExp(r":\d{1,5}$");
671
672 List<PveAccessDomainModel?>? response;
673
674 if (portRE.hasMatch(host)) {
675 response = await _tryLoadAccessDomains(apiBaseUrl);
676 } else {
677 // try to guess the port, 8006 first, and then 443
678 apiBaseUrl = apiBaseUrl.replace(port: 8006);
679 try {
680 response = await _loadAccessDomains(apiBaseUrl);
681 if (response != null) {
682 _originController.text = '$host:8006';
683 }
684 } catch (e) {
685 // we were no port given, and we couldn't reach on port 8006, retry with 443
686 apiBaseUrl = apiBaseUrl.replace(port: 443);
687 response = await _tryLoadAccessDomains(apiBaseUrl);
688 if (response != null) {
689 _originController.text = '$host:443';
690 }
691 }
692 }
693
694 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
695
696 final selection = response?.singleWhere(
697 (e) => e!.realm == widget.userModel?.realm,
698 orElse: () => response?.first,
699 );
700
701 setState(() {
702 _progressModel.inProgress -= 1;
703 _selectedDomain = selection;
704 });
705
706 return response;
707 }
708
709 @override
710 void dispose() {
711 _originController.dispose();
712 _usernameController.dispose();
713 _passwordController.dispose();
714 super.dispose();
715 }
716 }
717
718 class ProxmoxProgressOverlay extends StatelessWidget {
719 const ProxmoxProgressOverlay({
720 super.key,
721 required this.message,
722 });
723
724 final String message;
725
726 @override
727 Widget build(BuildContext context) {
728 return Container(
729 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
730 child: Center(
731 child: Column(
732 mainAxisAlignment: MainAxisAlignment.center,
733 children: [
734 Text(
735 message,
736 style: const TextStyle(
737 fontSize: 20,
738 ),
739 ),
740 const Padding(
741 padding: EdgeInsets.only(top: 20.0),
742 child: CircularProgressIndicator(),
743 )
744 ],
745 ),
746 ),
747 );
748 }
749 }
750
751 class ConnectionErrorDialog extends StatelessWidget {
752 final Object exception;
753
754 const ConnectionErrorDialog({
755 super.key,
756 required this.exception,
757 });
758
759 @override
760 Widget build(BuildContext context) {
761 return AlertDialog(
762 title: const Text('Connection error'),
763 content: Text('Could not establish connection: $exception'),
764 actions: [
765 TextButton(
766 onPressed: () => Navigator.of(context).pop(),
767 child: const Text('Close'),
768 ),
769 ],
770 );
771 }
772 }
773
774 class ProxmoxApiErrorDialog extends StatelessWidget {
775 final proxclient.ProxmoxApiException exception;
776
777 const ProxmoxApiErrorDialog({
778 super.key,
779 required this.exception,
780 });
781
782 @override
783 Widget build(BuildContext context) {
784 return AlertDialog(
785 title: const Text('API Error'),
786 content: SingleChildScrollView(
787 child: Text(exception.message),
788 ),
789 actions: [
790 TextButton(
791 onPressed: () => Navigator.of(context).pop(),
792 child: const Text('Close'),
793 ),
794 ],
795 );
796 }
797 }
798
799 class ProxmoxCertificateErrorDialog extends StatelessWidget {
800 const ProxmoxCertificateErrorDialog({
801 super.key,
802 });
803
804 @override
805 Widget build(BuildContext context) {
806 return AlertDialog(
807 title: const Text('Certificate error'),
808 content: SingleChildScrollView(
809 child: Column(
810 crossAxisAlignment: CrossAxisAlignment.start,
811 children: [
812 const Text('Your connection is not private.'),
813 Text(
814 'Note: Consider to disable SSL validation,'
815 ' if you use a self signed, not commonly trusted, certificate.',
816 style: Theme.of(context).textTheme.bodySmall,
817 ),
818 ],
819 ),
820 ),
821 actions: [
822 TextButton(
823 onPressed: () => Navigator.of(context).pop(),
824 child: const Text('Close'),
825 ),
826 TextButton(
827 onPressed: () => Navigator.of(context).pushReplacement(
828 MaterialPageRoute(
829 builder: (context) => const ProxmoxGeneralSettingsForm())),
830 child: const Text('Settings'),
831 )
832 ],
833 );
834 }
835 }
836
837 Uri normalizeUrl(String urlText) {
838 if (urlText.startsWith('https://')) {
839 urlText = urlText.substring('https://'.length);
840 }
841 if (urlText.startsWith('http://')) {
842 throw Exception("HTTP without TLS is not supported");
843 }
844
845 return Uri.https(urlText, '');
846 }