[deep link] Add more domain error types (#7156) * 1 * 2 * 1 * lint * expansiontile * string update * resolve comments * Update deep_links_screen_test.dart * resolve comments * UI update * lint * Update validation_details_view.dart * lint
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart index 7c990f4..d6dfd10 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart
@@ -15,6 +15,17 @@ import 'deep_links_services.dart'; typedef _DomainAndPath = ({String domain, String path}); +const domainErrorsThatCanBeFixedByGeneratedJson = { + DomainError.existence, + DomainError.appIdentifier, + DomainError.fingerprints, + DomainError.contentType, +}; +const domainErrorsThatCanNotBeFixedByGeneratedJson = { + DomainError.httpsAccessibility, + DomainError.nonRedirect, + DomainError.hostForm, +}; /// The phase of the deep link page. enum PagePhase { @@ -135,6 +146,8 @@ } DisplayOptions get displayOptions => displayOptionsNotifier.value; + String get applicationId => + _androidAppLinks[selectedVariantIndex.value]?.applicationId ?? ''; List<LinkData> get getLinkDatasByPath { final linkDatasByPath = <String, LinkData>{}; @@ -240,7 +253,8 @@ List<LinkData>? allValidatedLinkDatas; final displayLinkDatasNotifier = ValueNotifier<List<LinkData>?>(null); - final generatedAssetLinksForSelectedLink = ValueNotifier<String?>(null); + final generatedAssetLinksForSelectedLink = + ValueNotifier<GenerateAssetLinksResult?>(null); final displayOptionsNotifier = ValueNotifier<DisplayOptions>(DisplayOptions()); @@ -250,9 +264,7 @@ final deepLinksServices = DeepLinksServices(); Future<void> _generateAssetLinks() async { - final applicationId = - _androidAppLinks[selectedVariantIndex.value]?.applicationId ?? ''; - + generatedAssetLinksForSelectedLink.value = null; generatedAssetLinksForSelectedLink.value = await deepLinksServices.generateAssetLinks( domain: selectedLink.value!.domain, @@ -272,9 +284,6 @@ .toSet() .toList(); - final applicationId = - _androidAppLinks[selectedVariantIndex.value]?.applicationId ?? ''; - late final Map<String, List<DomainError>> domainErrors; try { @@ -289,10 +298,11 @@ } return linkdatas.map((linkdata) { - if (domainErrors[linkdata.domain]?.isNotEmpty ?? false) { + final errors = domainErrors[linkdata.domain]; + if (errors != null && errors.isNotEmpty) { return LinkData( domain: linkdata.domain, - domainErrors: domainErrors[linkdata.domain]!, + domainErrors: errors, path: linkdata.path, pathError: linkdata.pathError, os: linkdata.os,
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart index bffca5c..68f92eb 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart
@@ -24,13 +24,82 @@ final String description; } -// TODO(hangyujin): Handle more domain error cases. enum DomainError { - existence('Domain doesn\'t exist'), - fingerprints('Fingerprints unavailable'); + // Existence of an asset link file. + existence( + 'Digital Asset Links JSON file existence failed', + 'This test checks whether the assetlinks.json file, ' + 'which is used to verify the association between the app and the ' + 'domain name, exists under your domain.', + 'Add a Digital Asset Links JSON file to all of the ' + 'failed website domains at the following location: ' + 'https://[domain.name]/.well-known/assetlinks.json. See the following recommended asset link json file. ', + ), + // Asset link file should define a link to this app. + appIdentifier( + 'Package name failed', + 'The test checks your Digital Asset Links JSON file ' + 'for package name validation, which the mobile device ' + 'uses to verify ownership of the app.', + 'Ensure your Digital Asset Links JSON file declares the ' + 'correct package name with the "android_app" namespace for ' + 'all of the failed website domains. Also, confirm that the ' + 'app is available in the Google Play store. See the following recommended asset link json file. ', + ), + // Asset link file should contain the correct fingerprint. + fingerprints( + 'Fingerprint validation failed', + 'This test checks your Digital Asset Links JSON file for ' + 'sha256 fingerprint validation, which the mobile device uses ' + 'to verify ownership of the app.', + 'Add sha256_cert_fingerprints to the Digital Asset Links JSON ' + 'file for all of the failed website domains. If the fingerprint ' + 'has already been added, make sure it\'s correct and that the ' + '"android_app" namespace is declared on it. See the following recommended asset link json file. ', + ), + // Asset link file should be served with the correct content type. + contentType( + 'JSON content type failed', + 'This test checks your Digital Asset Links JSON file for content type ' + 'validation, which defines the format of the JSON file. This allows ' + 'the mobile device to verify ownership of the app.', + 'Ensure the content-type is "application/json" for all of the failed website domains. See the following recommended asset link json file. ', + ), + // Asset link file should be accessible via https. + httpsAccessibility( + 'HTTPS accessibility failed', + 'This test tries to access your Digital Asset Links ' + 'JSON file over an HTTPS connection, which must be ' + 'accessible to verify ownership of the app.', + 'Ensure your Digital Asset Links JSON file is accessible ' + 'over an HTTPS connection for all of the failed website domains (even if ' + 'the app\'s intent filter declares HTTP as the data scheme).', + ), - const DomainError(this.description); - final String description; + // Asset link file should be accessible with no redirects. + nonRedirect( + 'Domain non-redirect failed', + 'This test checks that your domain is accessible without ' + 'redirects. This domain must be directly accessible ' + 'to verify ownership of the app.', + 'Ensure your domain is accessible without any redirects ', + ), + + // Asset link domain should be valid/not malformed. + hostForm( + 'Host attribute formed properly failed', + 'This test checks that your android:host attribute has a valid domain URL pattern.', + 'Make sure the host is a properly formed web address such ' + 'as google.com or www.google.com, without "http://" or "https://".', + ), + // Issues that are not covered by other checks. An example that may be in this + // category is Android validation API failures. + other('Check failed', '', ''); + + const DomainError(this.title, this.explanation, this.fixDetails); + final String title; + final String explanation; + final String fixDetails; } /// Contains all data relevant to a deep link.
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart index 99e6ee1..3edf4eb 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart
@@ -24,8 +24,23 @@ const String _checkNameKey = 'checkName'; const String _failedChecksKey = 'failedChecks'; const String _generatedContentKey = 'generatedContent'; -const String _existenceCheckKey = 'EXISTENCE'; -const String _fingerPrintChecktKey = 'FINGERPRINT'; + +const Map<String, DomainError> checkNameToDomainError = { + 'EXISTENCE': DomainError.existence, + 'APP_IDENTIFIER': DomainError.appIdentifier, + 'FINGERPRINT': DomainError.fingerprints, + 'CONTENT_TYPE': DomainError.contentType, + 'HTTPS_ACCESSIBILITY': DomainError.httpsAccessibility, + 'NON_REDIRECT': DomainError.nonRedirect, + 'HOST_FORMED_PROPERLY': DomainError.hostForm, + 'OTHER_CHECKS': DomainError.other, +}; + +class GenerateAssetLinksResult { + GenerateAssetLinksResult(this.errorCode, this.generatedString); + String errorCode; + String generatedString; +} class DeepLinksServices { Future<Map<String, List<DomainError>>> validateAndroidDomain({ @@ -54,11 +69,10 @@ final List? failedChecks = domainResult[_failedChecksKey]; if (failedChecks != null) { for (final Map<String, dynamic> failedCheck in failedChecks) { - switch (failedCheck[_checkNameKey]) { - case _existenceCheckKey: - domainErrors[domainName]!.add(DomainError.existence); - case _fingerPrintChecktKey: - domainErrors[domainName]!.add(DomainError.fingerprints); + final checkName = failedCheck[_checkNameKey]; + final domainError = checkNameToDomainError[checkName]; + if (domainError != null) { + domainErrors[domainName]!.add(domainError); } } } @@ -66,7 +80,7 @@ return domainErrors; } - Future<String> generateAssetLinks({ + Future<GenerateAssetLinksResult> generateAssetLinks({ required String applicationId, required String domain, }) async { @@ -77,22 +91,19 @@ { _packageNameKey: applicationId, _domainsKey: [domain], - // TODO(hangyujin): Handle the error case when user doesn't have play console project set up. }, ), ); final Map<String, dynamic> result = json.decode(response.body) as Map<String, dynamic>; + final String errorCode = result[_errorCodeKey] ?? ''; + String generatedContent = ''; - if (result[_errorCodeKey] != null) { - return 'Content generation failed.\n Reason: ${result[_errorCodeKey]}'; - } if (result[_domainsKey] != null) { - final String generatedContent = (((result[_domainsKey] as List).first) + generatedContent = (((result[_domainsKey] as List).first) as Map<String, dynamic>)[_generatedContentKey]; - - return generatedContent; } - return ''; + + return GenerateAssetLinksResult(errorCode, generatedContent); } }
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart index 0656718..13f4518 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart
@@ -2,16 +2,21 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../shared/common_widgets.dart'; +import '../../shared/config_specific/launch_url/launch_url.dart'; import '../../shared/table/table.dart'; import '../../shared/ui/colors.dart'; import 'deep_link_list_view.dart'; import 'deep_links_controller.dart'; import 'deep_links_model.dart'; +import 'deep_links_services.dart'; class ValidationDetailView extends StatelessWidget { const ValidationDetailView({ @@ -39,10 +44,10 @@ crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'This tool assistants helps you diagnose Universal Links, App Links,' - ' and Custom Schemes in your app. Web check are done for the web association' + 'This tool helps you diagnose issues with App Links in your application.' + 'Web checks are done for the web association' ' file on your website. App checks are done for the intent filters in' - ' the manifest and info.plist file, routing issues, URL format, etc.', + ' the manifest and info.plist files, routing issues, URL format, etc.', style: Theme.of(context).subtleTextStyle, ), if (viewType == TableViewType.domainView || @@ -54,13 +59,14 @@ viewType == TableViewType.singleUrlView) _PathCheckTable(), const SizedBox(height: largeSpacing), - Align( - alignment: Alignment.bottomRight, - child: FilledButton( - onPressed: () async => await controller.validateLinks(), - child: const Text('Recheck all'), + if (linkData.domainErrors.isNotEmpty) + Align( + alignment: Alignment.bottomRight, + child: FilledButton( + onPressed: () async => await controller.validateLinks(), + child: const Text('Recheck all'), + ), ), - ), if (viewType == TableViewType.domainView) _DomainAssociatedLinksPanel(controller: controller), ], @@ -119,18 +125,19 @@ @override Widget build(BuildContext context) { final linkData = controller.selectedLink.value!; + final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: intermediateSpacing), - Text('Domain check', style: Theme.of(context).textTheme.titleSmall), + Text('Web check', style: theme.textTheme.titleSmall), const SizedBox(height: denseSpacing), DataTable( headingRowColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.deeplinkTableHeaderColor, + theme.colorScheme.deeplinkTableHeaderColor, ), dataRowColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.alternatingBackgroundColor2, + theme.colorScheme.alternatingBackgroundColor2, ), columns: const [ DataColumn(label: Text('OS')), @@ -149,15 +156,16 @@ DataCell( linkData.domainErrors.isNotEmpty ? Text( - 'Check failed', + '${linkData.domainErrors.length} ' + '${pluralize('Check', linkData.domainErrors.length)} failed', style: TextStyle( - color: Theme.of(context).colorScheme.error, + color: theme.colorScheme.error, ), ) : Text( 'No issues found', style: TextStyle( - color: Theme.of(context).colorScheme.green, + color: theme.colorScheme.green, ), ), ), @@ -171,8 +179,7 @@ DataCell( Text( 'No issues found', - style: - TextStyle(color: Theme.of(context).colorScheme.green), + style: TextStyle(color: theme.colorScheme.green), ), ), ], @@ -206,62 +213,28 @@ child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text('How to fix:'), - Text( - 'Add the new recommended Digital Asset Links JSON file to the failed website domain at the correct location.\n' - 'Update and publish recommend Digital Asset Links JSON file below to this location: ', - style: Theme.of(context).subtleTextStyle, - ), + _FailureDetails(linkData: linkData), + if (linkData.domainErrors.any( + (error) => + domainErrorsThatCanBeFixedByGeneratedJson.contains(error), + )) + _GenerateAssetLinksPanel(controller: controller), Align( alignment: Alignment.centerLeft, - child: Card( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(4.0)), - ), - color: Theme.of(context).colorScheme.outline, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: denseSpacing), - child: SelectionArea( - child: Text( - 'https://${linkData.domain}/.well-known/assetlinks.json', - style: Theme.of(context).regularTextStyle.copyWith( - color: Colors.black, - fontWeight: FontWeight.w500, - ), + child: TextButton( + onPressed: () { + unawaited( + launchUrl( + 'https://developer.android.com/training/app-links/verify-android-applinks', ), - ), + ); + }, + style: const ButtonStyle().copyWith( + textStyle: MaterialStateProperty.resolveWith<TextStyle>((_) { + return Theme.of(context).textTheme.bodySmall!; + }), ), - ), - ), - Card( - color: Theme.of(context).colorScheme.surface, - child: Padding( - padding: const EdgeInsets.all(denseSpacing), - child: ValueListenableBuilder( - valueListenable: - controller.generatedAssetLinksForSelectedLink, - builder: (_, String? generatedAssetLinks, __) => - generatedAssetLinks != null - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: SelectionArea( - child: Text(generatedAssetLinks), - ), - ), - IconButton( - onPressed: () async => - await Clipboard.setData( - ClipboardData(text: generatedAssetLinks), - ), - icon: const Icon(Icons.copy_rounded), - ), - ], - ) - : const CenteredCircularProgressIndicator(), - ), + child: const Text('View developer guide'), ), ), ], @@ -271,6 +244,158 @@ } } +class _GenerateAssetLinksPanel extends StatelessWidget { + const _GenerateAssetLinksPanel({ + required this.controller, + }); + + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: controller.generatedAssetLinksForSelectedLink, + builder: ( + _, + GenerateAssetLinksResult? generatedAssetLinks, + __, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const Text('Recommended Asset Links Json file :'), + const SizedBox(height: denseSpacing), + (generatedAssetLinks != null && + generatedAssetLinks.errorCode.isNotEmpty) + ? Text( + 'Not able to generate assetlinks.json, because the app ${controller.applicationId} is not uploaded to Google Play.', + style: theme.subtleTextStyle, + ) + : Column( + children: [ + Card( + color: theme.colorScheme.alternatingBackgroundColor1, + surfaceTintColor: Colors.transparent, + elevation: 0.0, + child: Padding( + padding: const EdgeInsets.all(denseSpacing), + child: generatedAssetLinks != null + ? Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: SelectionArea( + child: Text( + generatedAssetLinks.generatedString, + ), + ), + ), + IconButton( + onPressed: () async => + await Clipboard.setData( + ClipboardData( + text: generatedAssetLinks + .generatedString, + ), + ), + icon: const Icon(Icons.copy_rounded), + ), + ], + ) + : const CenteredCircularProgressIndicator(), + ), + ), + const SizedBox(height: denseSpacing), + Text( + 'Update and publish this new recommended Digital Asset Links JSON file below at this location:', + style: theme.subtleTextStyle, + ), + const SizedBox(height: denseSpacing), + Align( + alignment: Alignment.centerLeft, + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + color: theme.colorScheme.outline, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: denseSpacing, + ), + child: SelectionArea( + child: Text( + 'https://${controller.selectedLink.value!.domain}/.well-known/assetlinks.json', + style: theme.regularTextStyle.copyWith( + color: Colors.black, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + const SizedBox(height: denseSpacing), + ], + ), + ], + ); + }, + ); + } +} + +class _FailureDetails extends StatelessWidget { + const _FailureDetails({ + required this.linkData, + }); + + final LinkData linkData; + + @override + Widget build(BuildContext context) { + final errorCount = linkData.domainErrors.length; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < errorCount; i++) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: densePadding), + Row( + children: [ + Icon( + Icons.error, + color: Theme.of(context).colorScheme.error, + size: defaultIconSize, + ), + const SizedBox(width: denseSpacing), + Text('Issue ${i + 1} : ${linkData.domainErrors[i].title}'), + ], + ), + const SizedBox(height: densePadding), + Padding( + padding: EdgeInsets.only( + left: defaultIconSize + denseSpacing, + ), + child: Text( + linkData.domainErrors[i].explanation + + linkData.domainErrors[i].fixDetails, + style: Theme.of(context).subtleTextStyle, + ), + ), + ], + ), + ], + ); + } +} + class _DomainAssociatedLinksPanel extends StatelessWidget { const _DomainAssociatedLinksPanel({ required this.controller, @@ -280,16 +405,17 @@ @override Widget build(BuildContext context) { + final theme = Theme.of(context); final linkData = controller.selectedLink.value!; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Associated deep link URL', - style: Theme.of(context).textTheme.titleSmall, + style: theme.textTheme.titleSmall, ), Card( - color: Theme.of(context).colorScheme.surface, + color: theme.colorScheme.surface, shape: const RoundedRectangleBorder(), child: Padding( padding: const EdgeInsets.all(denseSpacing), @@ -306,7 +432,7 @@ if (linkData.domainErrors.isNotEmpty) Icon( Icons.error, - color: Theme.of(context).colorScheme.error, + color: theme.colorScheme.error, size: defaultIconSize, ), const SizedBox(width: denseSpacing), @@ -327,11 +453,12 @@ class _PathCheckTable extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final notAvailableCell = DataCell( Text( 'Not available', style: TextStyle( - color: Theme.of(context).colorScheme.deeplinkUnavailableColor, + color: theme.colorScheme.deeplinkUnavailableColor, ), ), ); @@ -341,7 +468,7 @@ const SizedBox(height: intermediateSpacing), Text( 'Path check (coming soon)', - style: Theme.of(context).textTheme.titleSmall, + style: theme.textTheme.titleSmall, ), Opacity( opacity: 0.5, @@ -350,10 +477,10 @@ dataRowMinHeight: defaultRowHeight, dataRowMaxHeight: defaultRowHeight, headingRowColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.deeplinkTableHeaderColor, + theme.colorScheme.deeplinkTableHeaderColor, ), dataRowColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.alternatingBackgroundColor2, + theme.colorScheme.alternatingBackgroundColor2, ), columns: const [ DataColumn(label: Text('OS')),
diff --git a/packages/devtools_app/test/deep_link_vlidation/deep_links_screen_test.dart b/packages/devtools_app/test/deep_link_vlidation/deep_links_screen_test.dart index 3f1bd71..208c432 100644 --- a/packages/devtools_app/test/deep_link_vlidation/deep_links_screen_test.dart +++ b/packages/devtools_app/test/deep_link_vlidation/deep_links_screen_test.dart
@@ -5,6 +5,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_link_list_view.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_links_model.dart'; +import 'package:devtools_app/src/screens/deep_link_validation/deep_links_services.dart'; import 'package:devtools_app/src/screens/deep_link_validation/validation_details_view.dart'; import 'package:devtools_app/src/shared/directory_picker.dart'; import 'package:devtools_app_shared/ui.dart'; @@ -491,7 +492,10 @@ void selectLink(LinkData linkdata) async { selectedLink.value = linkdata; if (linkdata.domainErrors.isNotEmpty) { - generatedAssetLinksForSelectedLink.value = 'fake generated content'; + generatedAssetLinksForSelectedLink.value = GenerateAssetLinksResult( + '', + 'fake generated content', + ); } } }