diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart index 8f1508c22a4..ee95e795c09 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; @@ -13,9 +14,15 @@ import '../../shared/primitives/utils.dart'; import '../../shared/table/table.dart'; import '../../shared/table/table_data.dart'; import '../../shared/ui/colors.dart'; +import '../../shared/ui/tab.dart'; import '../../shared/utils.dart'; import 'deep_links_controller.dart'; import 'deep_links_model.dart'; +import 'validation_details_view.dart'; + +const _kNotificationCardSize = Size(475, 132); +const _kSearchFieldFullWidth = 314.0; +const _kSearchFieldSplitScreenWidth = 280.0; enum TableViewType { domainView, @@ -47,6 +54,7 @@ class _DeepLinkListViewState extends State // If not found, default to 0. releaseVariantIndex = max(releaseVariantIndex, 0); controller.selectedVariantIndex.value = releaseVariantIndex; + unawaited(controller.validateLinks()); }); } @@ -54,13 +62,14 @@ class _DeepLinkListViewState extends State Widget build(BuildContext context) { return DefaultTabController( length: TableViewType.values.length, - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DeepLinkListViewTopPanel(), - SizedBox(height: denseSpacing), - Expanded(child: _DeepLinkListViewMainPanel()), - ], + child: const RoundedOutlinedBorder( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DeepLinkListViewTopPanel(), + Expanded(child: _DeepLinkListViewMainPanel()), + ], + ), ), ); } @@ -72,112 +81,64 @@ class _DeepLinkListViewMainPanel extends StatelessWidget { @override Widget build(BuildContext context) { final controller = Provider.of(context); - return ValueListenableBuilder?>( - valueListenable: controller.linkDatasNotifier, - builder: (context, linkDatas, _) { - if (linkDatas == null) { - return const CenteredCircularProgressIndicator(); - } - return Column( - children: [ - AreaPaneHeader( - title: Text( - 'All deep links', - style: Theme.of(context).textTheme.bodyLarge, - ), - actions: [ - SizedBox( - width: wideSearchFieldWidth, - child: DevToolsClearableTextField( - labelText: '', - hintText: 'Search a URL, domain or path', - prefixIcon: const Icon(Icons.search), - onChanged: (value) { - controller.searchContent = value; - }, - ), + // TODO(hangyujin): Use MultiValueListenableBuilder. + return ValueListenableBuilder( + valueListenable: controller.displayOptionsNotifier, + builder: (context, displayOptions, _) => + ValueListenableBuilder?>( + valueListenable: controller.allLinkDatasNotifier, + builder: (context, linkDatas, _) { + if (linkDatas == null) { + return const CenteredCircularProgressIndicator(); + } + if (displayOptions.showSplitScreen) { + return Row( + children: [ + Expanded( + child: _AllDeepLinkDataTable(controller: controller), ), - ], - ), - const SizedBox(height: denseSpacing), - const TabBar( - tabs: [ - Text('Domain view'), - Text('Path view'), - Text('Single URL view'), - ], - tabAlignment: TabAlignment.start, - isScrollable: true, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: controller.showSpitScreenNotifier, - builder: (context, showSpitScreen, _) => TabBarView( - children: [ - _DataTableWithValidationDetails( - tableView: TableViewType.domainView, - linkDatas: controller.getLinkDatasByDomain, - controller: controller, - showSpitScreen: showSpitScreen, - ), - _DataTableWithValidationDetails( - tableView: TableViewType.pathView, - linkDatas: controller.getLinkDatasByPath, - controller: controller, - showSpitScreen: showSpitScreen, - ), - _DataTableWithValidationDetails( - tableView: TableViewType.singleUrlView, - linkDatas: linkDatas, - controller: controller, - showSpitScreen: showSpitScreen, + Expanded( + child: ValueListenableBuilder( + valueListenable: controller.selectedLink, + builder: (context, selectedLink, _) => TabBarView( + children: [ + ValidationDetailView( + linkData: selectedLink!, + controller: controller, + viewType: TableViewType.domainView, + ), + ValidationDetailView( + linkData: selectedLink, + controller: controller, + viewType: TableViewType.pathView, + ), + ValidationDetailView( + linkData: selectedLink, + controller: controller, + viewType: TableViewType.singleUrlView, + ), + ], ), - ], + ), ), - ), - ), - ], - ); - }, - ); - } -} - -class _DataTableWithValidationDetails extends StatelessWidget { - const _DataTableWithValidationDetails({ - required this.linkDatas, - required this.tableView, - required this.controller, - required this.showSpitScreen, - }); - final List linkDatas; - final TableViewType tableView; - final DeepLinksController controller; - final bool showSpitScreen; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _DataTable( - tableView: tableView, - linkDatas: linkDatas, - controller: controller, - ), - ), - if (showSpitScreen) - Expanded( - child: ValueListenableBuilder( - valueListenable: controller.selectedLink, - builder: (context, selectedLink, _) => _ValidationDetailScreen( - tableView: tableView, - linkData: selectedLink!, + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _NotificationCardSection( + domainErrorCount: displayOptions.domainErrorCount, + pathErrorCount: displayOptions.pathErrorCount, controller: controller, ), - ), - ), - ], + Expanded( + child: _AllDeepLinkDataTable(controller: controller), + ), + ], + ); + }, + ), ); } } @@ -185,153 +146,72 @@ class _DataTableWithValidationDetails extends StatelessWidget { class _DataTable extends StatelessWidget { const _DataTable({ required this.linkDatas, - required this.tableView, + required this.viewType, required this.controller, }); final List linkDatas; - final TableViewType tableView; + final TableViewType viewType; final DeepLinksController controller; @override Widget build(BuildContext context) { - final ColumnData domain = DomainColumn(); - final ColumnData path = PathColumn(); - - return FlatTable( - keyFactory: (node) => ValueKey(node.toString), - data: linkDatas, - dataKey: 'deep-links', - autoScrollContent: true, - columns: [ - if (tableView != TableViewType.pathView) domain, - if (tableView != TableViewType.domainView) path, - SchemeColumn(), - OSColumn(), - if (!controller.showSpitScreen) ...[ - StatusColumn(), - NavigationColumn(), - ], - ], - selectionNotifier: controller.selectedLink, - defaultSortColumn: tableView == TableViewType.pathView ? path : domain, - defaultSortDirection: SortDirection.ascending, - onItemSelected: (item) => controller.showSpitScreenNotifier.value = true, - ); - } -} - -class _ValidationDetailScreen extends StatelessWidget { - const _ValidationDetailScreen({ - required this.linkData, - required this.tableView, - required this.controller, - }); - - final LinkData linkData; - final TableViewType tableView; - final DeepLinksController controller; + final domain = DomainColumn(controller); + final path = PathColumn(controller); - @override - Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: largeSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Selected deep link validation details'), - IconButton( - onPressed: () => - controller.showSpitScreenNotifier.value = false, - icon: const Icon(Icons.close), - ), - ], - ), - Text( - 'This tool helps you diagnose Universal Links, App Links,' - ' and Custom Schemes in your app. Web checks are done for the web association' - ' files on your website. App checks are done for the intent filters in' - ' the manifest and info.plist file, routing issues, URL format, etc.', - style: Theme.of(context).textTheme.bodySmall, - ), - const Text('Domain check'), - Expanded(child: _DomainCheckTable(linkData: linkData)), + padding: const EdgeInsets.only(top: denseSpacing), + child: FlatTable( + keyFactory: (node) => ValueKey(node.toString), + data: linkDatas, + dataKey: 'deep-links', + autoScrollContent: true, + headerColor: Theme.of(context).colorScheme.deeplinkTableHeaderColor, + columns: >[ + ...(() { + switch (viewType) { + case TableViewType.domainView: + return [domain, NumberOfAssociatedPathColumn()]; + case TableViewType.pathView: + return [path, NumberOfAssociatedDomainColumn()]; + case TableViewType.singleUrlView: + return >[domain, path]; + } + })(), + SchemeColumn(controller), + OSColumn(controller), + if (!controller.displayOptionsNotifier.value.showSplitScreen) ...[ + StatusColumn(controller, viewType), + NavigationColumn(), + ], ], + selectionNotifier: controller.selectedLink, + defaultSortColumn: (viewType == TableViewType.pathView ? path : domain) + as ColumnData, + defaultSortDirection: SortDirection.ascending, + onItemSelected: (linkdata) { + controller.selectLink(linkdata!); + controller.updateDisplayOptions(showSplitScreen: true); + }, ), ); } } -class _DomainCheckTable extends StatelessWidget { - const _DomainCheckTable({ - required this.linkData, - }); - - final LinkData linkData; - - @override - Widget build(BuildContext context) { - return DataTable( - columns: const [ - DataColumn(label: Text('OS')), - DataColumn(label: Text('Issue type')), - DataColumn(label: Text('Status')), - ], - rows: [ - if (linkData.os.contains(PlatformOS.android)) - DataRow( - cells: [ - const DataCell(Text('Android')), - const DataCell(Text('Digital assets link file')), - DataCell( - linkData.domainError - ? Text( - 'Check failed', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ) - : Text( - 'No issues found', - style: TextStyle( - color: Theme.of(context).colorScheme.green, - ), - ), - ), - ], - ), - if (linkData.os.contains(PlatformOS.ios)) - DataRow( - cells: [ - const DataCell(Text('iOS')), - const DataCell(Text('Apple-App-Site-Association file')), - DataCell( - Text( - 'No issues found', - style: TextStyle(color: Theme.of(context).colorScheme.green), - ), - ), - ], - ), - ], - ); - } -} - class _DeepLinkListViewTopPanel extends StatelessWidget { const _DeepLinkListViewTopPanel(); @override Widget build(BuildContext context) { final controller = Provider.of(context); - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: const EdgeInsets.all(defaultSpacing), - child: ValueListenableBuilder( + return AreaPaneHeader( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Validate and fix', + style: Theme.of(context).textTheme.bodyLarge, + ), + ValueListenableBuilder( valueListenable: controller.selectedVariantIndex, builder: (_, value, __) { return _AndroidVariantDropdown( @@ -344,8 +224,8 @@ class _DeepLinkListViewTopPanel extends StatelessWidget { ); }, ), - ), - ], + ], + ), ); } } @@ -382,3 +262,222 @@ class _AndroidVariantDropdown extends StatelessWidget { ); } } + +class _AllDeepLinkDataTable extends StatelessWidget { + const _AllDeepLinkDataTable({ + required this.controller, + }); + + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + const gaPrefix = 'deepLinkTab'; + return Column( + children: [ + OutlineDecoration( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: defaultSpacing), + child: Text( + 'All deep links', + style: textTheme.bodyLarge, + ), + ), + Padding( + padding: const EdgeInsets.all(denseSpacing), + child: SizedBox( + width: controller.displayOptions.showSplitScreen + ? _kSearchFieldSplitScreenWidth + : _kSearchFieldFullWidth, + child: DevToolsClearableTextField( + labelText: '', + hintText: 'Search a URL, domain or path', + prefixIcon: const Icon(Icons.search), + onChanged: (value) { + controller.searchContent = value; + }, + ), + ), + ), + ], + ), + ), + TabBar( + tabs: [ + DevToolsTab.create( + tabName: 'Domain view', + gaPrefix: gaPrefix, + ), + DevToolsTab.create( + tabName: 'Path view', + gaPrefix: gaPrefix, + ), + DevToolsTab.create( + tabName: 'Single URL view', + gaPrefix: gaPrefix, + ), + ], + tabAlignment: TabAlignment.start, + isScrollable: true, + ), + Expanded( + child: ValueListenableBuilder?>( + valueListenable: controller.displayLinkDatasNotifier, + builder: (context, linkDatas, _) => TabBarView( + children: [ + _DataTable( + viewType: TableViewType.domainView, + linkDatas: controller.getLinkDatasByDomain, + controller: controller, + ), + _DataTable( + viewType: TableViewType.pathView, + linkDatas: controller.getLinkDatasByPath, + controller: controller, + ), + _DataTable( + viewType: TableViewType.singleUrlView, + linkDatas: linkDatas!, + controller: controller, + ), + ], + ), + ), + ), + ], + ); + } +} + +class _NotificationCardSection extends StatelessWidget { + const _NotificationCardSection({ + required this.domainErrorCount, + required this.pathErrorCount, + required this.controller, + }); + + final int domainErrorCount; + final int pathErrorCount; + + final DeepLinksController controller; + @override + Widget build(BuildContext context) { + if (domainErrorCount == 0 && domainErrorCount == 0) { + return const SizedBox.shrink(); + } + return OutlineDecoration( + child: Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: Row( + children: [ + if (domainErrorCount > 0) + _NotificationCard( + title: '$domainErrorCount domain not verified', + description: + 'This affects all deep links. Fix issues to make users go directly to your app.', + actionButton: TextButton( + onPressed: () { + // Switch to the domain view. Select the first link with domain error and show the split screen. + DefaultTabController.of(context).index = 0; + controller.selectLink( + controller.getLinkDatasByDomain + .where((element) => element.domainErrors.isNotEmpty) + .first, + ); + controller.updateDisplayOptions(showSplitScreen: true); + }, + child: const Text('Fix domain'), + ), + ), + if (domainErrorCount > 0 && pathErrorCount > 0) + const SizedBox(width: defaultSpacing), + if (pathErrorCount > 0) + _NotificationCard( + title: '$pathErrorCount path not working', + description: + 'Fix these path to make sure users are directed to your app', + actionButton: TextButton( + onPressed: () { + // Switch to the path view. Select the first link with path error and show the split screen. + DefaultTabController.of(context).index = 1; + controller.selectLink( + controller.getLinkDatasByPath + .where((element) => element.pathError) + .first, + ); + controller.updateDisplayOptions(showSplitScreen: true); + }, + child: const Text('Fix path'), + ), + ), + ], + ), + ), + ); + } +} + +class _NotificationCard extends StatelessWidget { + const _NotificationCard({ + required this.title, + required this.description, + required this.actionButton, + }); + + final String title; + final String description; + final Widget actionButton; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return SizedBox.fromSize( + size: _kNotificationCardSize, + child: Card( + color: colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB( + defaultSpacing, + defaultSpacing, + defaultSpacing, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error, color: colorScheme.error), + const SizedBox(width: denseSpacing), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyMedium! + .copyWith(color: colorScheme.onSurface), + ), + Text( + description, + style: Theme.of(context).subtleTextStyle, + ), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: actionButton, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} 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 6303b225b8d..88c1ba60bc7 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 @@ -3,45 +3,188 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; +import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_deeplink.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/config_specific/server/server.dart' as server; import 'deep_links_model.dart'; +const String _apiKey = 'AIzaSyDVE6FP3GpwxgS4q8rbS7qaf6cAbxc_elc'; +const String _assetLinksGenerationURL = + 'https://deeplinkassistant-pa.googleapis.com/android/generation/v1/assetlinks:generate?key=$_apiKey'; +const String _androidDomainValidationURL = + 'https://deeplinkassistant-pa.googleapis.com/android/validation/v1/domains:batchValidate?key=$_apiKey'; +const postHeader = {'Content-Type': 'application/json'}; +const String _packageNameKey = 'package_name'; +const String _domainsKey = 'domains'; +const String _appLinkDomainsKey = 'app_link_domains'; +const String _validationResultKey = 'validationResult'; +const String _domainNameKey = 'domainName'; +const String _checkNameKey = 'checkName'; +const String _failedChecksKey = 'failedChecks'; +const String _generatedContentKey = 'generatedContent'; + typedef _DomainAndPath = ({String domain, String path}); -class DeepLinksController { +enum FilterOption { + http('http://, https://'), + custom('Custom scheme'), + android('Android'), + ios('iOS'), + noIssue('No issues found'), + failedDomainCheck('Failed domain checks '), + failedPathCheck('Failed path checks'); + + const FilterOption(this.description); + final String description; +} + +enum SortingOption { + aToZ('A-Z'), + zToA('Z-A'), + errorOnTop('Error on top'); + + const SortingOption(this.description); + final String description; +} + +class DisplayOptions { + DisplayOptions({ + this.domainErrorCount = 0, + this.pathErrorCount = 0, + this.showSplitScreen = false, + this.filters = const { + FilterOption.http, + FilterOption.custom, + FilterOption.android, + FilterOption.ios, + FilterOption.noIssue, + FilterOption.failedDomainCheck, + FilterOption.failedPathCheck, + }, + this.searchContent = '', + // Default to show result with error first. + this.domainSortingOption = SortingOption.errorOnTop, + this.pathSortingOption = SortingOption.errorOnTop, + }); + + int domainErrorCount = 0; + int pathErrorCount = 0; + bool showSplitScreen = false; + String searchContent; + SortingOption? domainSortingOption; + SortingOption? pathSortingOption; + + final Set filters; + + DisplayOptions updateFilter(FilterOption option, bool value) { + final newFilter = Set.of(filters); + + if (value) { + newFilter.add(option); + } else { + newFilter.remove(option); + } + + return DisplayOptions( + domainErrorCount: domainErrorCount, + pathErrorCount: pathErrorCount, + showSplitScreen: showSplitScreen, + filters: newFilter, + searchContent: searchContent, + domainSortingOption: domainSortingOption, + pathSortingOption: pathSortingOption, + ); + } + + DisplayOptions copyWith({ + int? domainErrorCount, + int? pathErrorCount, + bool? showSplitScreen, + String? searchContent, + SortingOption? domainSortingOption, + SortingOption? pathSortingOption, + }) { + return DisplayOptions( + domainErrorCount: domainErrorCount ?? this.domainErrorCount, + pathErrorCount: pathErrorCount ?? this.pathErrorCount, + showSplitScreen: showSplitScreen ?? this.showSplitScreen, + filters: filters, + searchContent: searchContent ?? '', + domainSortingOption: domainSortingOption ?? this.domainSortingOption, + pathSortingOption: pathSortingOption ?? this.pathSortingOption, + ); + } +} + +class DeepLinksController extends DisposableController { DeepLinksController() { selectedVariantIndex.addListener(_handleSelectedVariantIndexChanged); } - bool get showSpitScreen => showSpitScreenNotifier.value; + @override + void dispose() { + super.dispose(); + selectedVariantIndex.removeListener(_handleSelectedVariantIndexChanged); + } + + DisplayOptions get displayOptions => displayOptionsNotifier.value; List get getLinkDatasByPath { final linkDatasByPath = {}; - for (var linkData in linkDatasNotifier.value!) { - linkDatasByPath[linkData.path] = linkData; + for (var linkData in allLinkDatasNotifier.value!) { + final previousRecord = linkDatasByPath[linkData.path]; + linkDatasByPath[linkData.path] = LinkData( + domain: linkData.domain, + path: linkData.path, + os: [ + if (previousRecord?.os.contains(PlatformOS.android) ?? + false || linkData.os.contains(PlatformOS.android)) + PlatformOS.android, + if (previousRecord?.os.contains(PlatformOS.ios) ?? + false || linkData.os.contains(PlatformOS.ios)) + PlatformOS.ios, + ], + associatedDomains: [ + ...previousRecord?.associatedDomains ?? [], + linkData.domain, + ], + pathError: linkData.pathError, + ); } - return linkDatasByPath.values.toList(); + + return _getFilterredLinks(linkDatasByPath.values.toList()); } List get getLinkDatasByDomain { final linkDatasByDomain = {}; - for (var linkData in linkDatasNotifier.value!) { - linkDatasByDomain[linkData.domain] = linkData; + + for (var linkData in allLinkDatasNotifier.value!) { + final previousRecord = linkDatasByDomain[linkData.domain]; + linkDatasByDomain[linkData.domain] = LinkData( + domain: linkData.domain, + path: linkData.path, + os: linkData.os, + associatedPath: [ + ...previousRecord?.associatedPath ?? [], + linkData.path, + ], + domainErrors: linkData.domainErrors, + ); } - return linkDatasByDomain.values.toList(); + return _getFilterredLinks(linkDatasByDomain.values.toList()); } final Map _androidAppLinks = {}; late final selectedVariantIndex = ValueNotifier(0); void _handleSelectedVariantIndexChanged() { - linkDatasNotifier.value = null; unawaited(_loadAndroidAppLinks()); } @@ -61,7 +204,7 @@ class DeepLinksController { }, ); } - _updateLinks(); + await validateLinks(); } List get _allLinkDatas { @@ -91,30 +234,193 @@ class DeepLinksController { final selectedProject = ValueNotifier(null); final selectedLink = ValueNotifier(null); - final linkDatasNotifier = ValueNotifier?>(null); - final showSpitScreenNotifier = ValueNotifier(false); - - final _searchContentNotifier = ValueNotifier(''); - - void _updateLinks() { - final searchContent = _searchContentNotifier.value; - final List linkDatas = searchContent.isNotEmpty - ? _allLinkDatas - .where( - (linkData) => linkData.matchesSearchToken( - RegExp( - searchContent, - caseSensitive: false, - ), - ), - ) - .toList() - : _allLinkDatas; - linkDatasNotifier.value = linkDatas; + + final allLinkDatasNotifier = ValueNotifier?>(null); + final displayLinkDatasNotifier = ValueNotifier?>(null); + final generatedAssetLinksForSelectedLink = ValueNotifier(null); + + final displayOptionsNotifier = + ValueNotifier(DisplayOptions()); + + Future _generateAssetLinks() async { + final applicationId = + _androidAppLinks[selectedVariantIndex.value]?.applicationId ?? ''; + + final response = await http.post( + Uri.parse(_assetLinksGenerationURL), + headers: postHeader, + body: jsonEncode( + { + _packageNameKey: applicationId, + _domainsKey: [selectedLink.value!.domain], + // TODO(hangyujin): The fake fingerprints here is just for testing usage, should remove it later. + // TODO(hangyujin): Handle the error case when user doesn't have play console project set up. + 'supplemental_sha256_cert_fingerprints': [ + '5A:33:EA:64:09:97:F2:F0:24:21:0F:B6:7A:A8:18:1C:18:A9:83:03:20:21:8F:9B:0B:98:BF:43:69:C2:AF:4A', + ], + }, + ), + ); + + final Map result = + json.decode(response.body) as Map; + if (result[_domainsKey] != null) { + final String generatedContent = + (result[_domainsKey] as List>) + .first[_generatedContentKey]; + + generatedAssetLinksForSelectedLink.value = generatedContent; + } + } + + Future> _validateAndroidDomain() async { + final List linkdatas = _allLinkDatas; + final domains = linkdatas + .where((linkdata) => linkdata.os.contains(PlatformOS.android)) + .map((linkdata) => linkdata.domain) + .toSet() + .toList(); + + final applicationId = + _androidAppLinks[selectedVariantIndex.value]?.applicationId ?? ''; + + final response = await http.post( + Uri.parse(_androidDomainValidationURL), + headers: postHeader, + body: jsonEncode({ + _packageNameKey: applicationId, + _appLinkDomainsKey: domains, + }), + ); + + final Map result = + json.decode(response.body) as Map; + + final Map> domainErrors = { + for (var domain in domains) domain: [], + }; + + final validationResult = result[_validationResultKey] as List; + for (final Map domainResult in validationResult) { + final String domainName = domainResult[_domainNameKey]; + final List? failedChecks = domainResult[_failedChecksKey]; + if (failedChecks != null) { + for (final Map failedCheck in failedChecks) { + switch (failedCheck[_checkNameKey]) { + case 'EXISTENCE': + domainErrors[domainName]!.add(DomainError.existence); + case 'FINGERPRINT': + domainErrors[domainName]!.add(DomainError.fingerprints); + } + } + } + } + + return linkdatas.map((linkdata) { + if (domainErrors[linkdata.domain]?.isNotEmpty ?? false) { + return LinkData( + domain: linkdata.domain, + domainErrors: domainErrors[linkdata.domain]!, + path: linkdata.path, + pathError: linkdata.pathError, + os: linkdata.os, + scheme: linkdata.scheme, + associatedDomains: linkdata.associatedDomains, + associatedPath: linkdata.associatedPath, + ); + } + return linkdata; + }).toList(); + } + + Future validateLinks() async { + allLinkDatasNotifier.value = await _validateAndroidDomain(); + displayLinkDatasNotifier.value = + _getFilterredLinks(allLinkDatasNotifier.value!); + + displayOptionsNotifier.value = displayOptionsNotifier.value.copyWith( + domainErrorCount: getLinkDatasByDomain + .where((element) => element.domainErrors.isNotEmpty) + .length, + pathErrorCount: + getLinkDatasByPath.where((element) => element.pathError).length, + ); + } + + void selectLink(LinkData linkdata) async { + selectedLink.value = linkdata; + if (linkdata.domainErrors.isNotEmpty) { + await _generateAssetLinks(); + } } set searchContent(String content) { - _searchContentNotifier.value = content; - _updateLinks(); + displayOptionsNotifier.value = + displayOptionsNotifier.value.copyWith(searchContent: content); + displayLinkDatasNotifier.value = + _getFilterredLinks(allLinkDatasNotifier.value!); + } + + void updateDisplayOptions({ + int? domainErrorCount, + int? pathErrorCount, + bool? showSplitScreen, + SortingOption? domainSortingOption, + SortingOption? pathSortingOption, + FilterOption? addedFilter, + FilterOption? removedFilter, + }) { + displayOptionsNotifier.value = displayOptionsNotifier.value.copyWith( + domainErrorCount: domainErrorCount, + pathErrorCount: pathErrorCount, + showSplitScreen: showSplitScreen, + domainSortingOption: domainSortingOption, + pathSortingOption: pathSortingOption, + ); + if (addedFilter != null) { + displayOptionsNotifier.value = + displayOptionsNotifier.value.updateFilter(addedFilter, true); + } + if (removedFilter != null) { + displayOptionsNotifier.value = + displayOptionsNotifier.value.updateFilter(removedFilter, false); + } + + displayLinkDatasNotifier.value = + _getFilterredLinks(allLinkDatasNotifier.value!); + } + + List _getFilterredLinks(List linkDatas) { + final String searchContent = displayOptions.searchContent; + linkDatas = linkDatas.where((linkData) { + if (searchContent.isNotEmpty && + !linkData.matchesSearchToken( + RegExp(searchContent, caseSensitive: false), + )) { + return false; + } + + if (!((linkData.os.contains(PlatformOS.android) && + displayOptions.filters.contains(FilterOption.android)) || + (linkData.os.contains(PlatformOS.ios) && + displayOptions.filters.contains(FilterOption.ios)))) { + return false; + } + + if (!((linkData.domainErrors.isNotEmpty && + displayOptions.filters + .contains(FilterOption.failedDomainCheck)) || + (linkData.pathError && + displayOptions.filters.contains(FilterOption.failedPathCheck)) || + (!linkData.domainErrors.isNotEmpty && + !linkData.pathError && + displayOptions.filters.contains(FilterOption.noIssue)))) { + return false; + } + + return true; + }).toList(); + + return linkDatas; } } 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 5ef2d74d3e2..ebdd04290c9 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 @@ -10,12 +10,27 @@ import '../../shared/table/table.dart'; import '../../shared/table/table_data.dart'; import '../../shared/ui/colors.dart'; import '../../shared/ui/search.dart'; +import 'deep_link_list_view.dart'; +import 'deep_links_controller.dart'; const kDeeplinkTableCellDefaultWidth = 200.0; +const kToolTipWidth = 344.0; enum PlatformOS { - android, - ios, + android('Android'), + ios('iOS'); + + const PlatformOS(this.description); + final String description; +} + +// TODO(hangyujin): Handle more domain error cases. +enum DomainError { + existence('Domain doesn\'t exist'), + fingerprints('Fingerprints unavailable'); + + const DomainError(this.description); + final String description; } /// Contains all data relevant to a deep link. @@ -24,18 +39,23 @@ class LinkData with SearchableDataMixin { required this.domain, required this.path, required this.os, - this.scheme = const ['Http://', 'Https://'], - this.domainError = false, + this.scheme = const ['http://', 'https://'], + this.domainErrors = const [], this.pathError = false, + this.associatedPath = const [], + this.associatedDomains = const [], }); final String path; final String domain; final List os; final List scheme; - final bool domainError; + final List domainErrors; final bool pathError; + final List associatedPath; + final List associatedDomains; + @override bool matchesSearchToken(RegExp regExpSearch) { return domain.caseInsensitiveContains(regExpSearch) || @@ -50,27 +70,72 @@ class _ErrorAwareText extends StatelessWidget { const _ErrorAwareText({ required this.text, required this.isError, + required this.controller, + required this.link, }); final String text; final bool isError; + final DeepLinksController controller; + final LinkData link; @override Widget build(BuildContext context) { return Row( children: [ if (isError) - Padding( - padding: const EdgeInsets.only(right: denseSpacing), - child: Icon( - Icons.error, - color: Theme.of(context).colorScheme.error, - size: defaultIconSize, + DevToolsTooltip( + padding: const EdgeInsets.only( + top: defaultSpacing, + left: defaultSpacing, + right: defaultSpacing, + ), + preferBelow: true, + richMessage: WidgetSpan( + child: SizedBox( + width: kToolTipWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'This m.shopping.com domain has ${link.domainErrors.length} issue to fix. ' + 'Fixing this domain will fix ${link.associatedPath.length} associated deep links.', + style: TextStyle( + color: Theme.of(context).colorScheme.tooltipTextColor, + fontSize: defaultFontSize, + ), + ), + TextButton( + onPressed: () { + controller.updateDisplayOptions(showSplitScreen: true); + controller.selectLink(link); + }, + child: Text( + 'Fix this domain', + style: TextStyle( + color: Theme.of(context).colorScheme.inversePrimary, + fontSize: defaultFontSize, + ), + ), + ), + ], + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(right: denseSpacing), + child: Icon( + Icons.error, + color: Theme.of(context).colorScheme.error, + size: defaultIconSize, + ), ), ), const SizedBox(width: denseSpacing), - Text( - text, - overflow: TextOverflow.ellipsis, + Flexible( + child: Text( + text, + overflow: TextOverflow.ellipsis, + ), ), ], ); @@ -78,15 +143,35 @@ class _ErrorAwareText extends StatelessWidget { } class DomainColumn extends ColumnData - implements ColumnRenderer { - DomainColumn() + implements ColumnRenderer, ColumnHeaderRenderer { + DomainColumn(this.controller) : super( 'Domain', fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), ); + DeepLinksController controller; + @override - bool get supportsSorting => true; + Widget? buildHeader( + BuildContext context, + Widget Function() defaultHeaderRenderer, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Domain'), + PopupMenuButton( + itemBuilder: (BuildContext context) => + _buildPopupMenuSortingEntries(controller, isPath: false), + child: Icon( + Icons.arrow_drop_down, + size: actionsIconSize, + ), + ), + ], + ); + } @override String getValue(LinkData dataObject) => dataObject.domain; @@ -99,22 +184,52 @@ class DomainColumn extends ColumnData VoidCallback? onPressed, }) { return _ErrorAwareText( - isError: dataObject.domainError, + isError: dataObject.domainErrors.isNotEmpty, + controller: controller, text: dataObject.domain, + link: dataObject, ); } + + @override + int compare(LinkData a, LinkData b) => _compareLinkData( + a, + b, + sortingOption: controller.displayOptions.domainSortingOption, + compareDomain: true, + ); } class PathColumn extends ColumnData - implements ColumnRenderer { - PathColumn() + implements ColumnRenderer, ColumnHeaderRenderer { + PathColumn(this.controller) : super( 'Path', fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), ); + DeepLinksController controller; + @override - bool get supportsSorting => true; + Widget? buildHeader( + BuildContext context, + Widget Function() defaultHeaderRenderer, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Path'), + PopupMenuButton( + itemBuilder: (BuildContext context) => + _buildPopupMenuSortingEntries(controller, isPath: true), + child: Icon( + Icons.arrow_drop_down, + size: actionsIconSize, + ), + ), + ], + ); + } @override String getValue(LinkData dataObject) => dataObject.path; @@ -128,44 +243,159 @@ class PathColumn extends ColumnData }) { return _ErrorAwareText( isError: dataObject.pathError, + controller: controller, text: dataObject.path, + link: dataObject, ); } + + @override + int compare(LinkData a, LinkData b) => _compareLinkData( + a, + b, + sortingOption: controller.displayOptions.pathSortingOption, + compareDomain: false, + ); +} + +class NumberOfAssociatedPathColumn extends ColumnData { + NumberOfAssociatedPathColumn() + : super( + 'Number of associated path', + fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), + ); + + @override + String getValue(LinkData dataObject) => + dataObject.associatedPath.length.toString(); +} + +class NumberOfAssociatedDomainColumn extends ColumnData { + NumberOfAssociatedDomainColumn() + : super( + 'Number of associated domain', + fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), + ); + + @override + String getValue(LinkData dataObject) => + dataObject.associatedDomains.length.toString(); } -class SchemeColumn extends ColumnData { - SchemeColumn() +class SchemeColumn extends ColumnData + implements ColumnRenderer, ColumnHeaderRenderer { + SchemeColumn(this.controller) : super( 'Scheme', fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), ); + DeepLinksController controller; + @override - String getValue(LinkData dataObject) => dataObject.scheme.join(','); + Widget? buildHeader( + BuildContext context, + Widget Function() defaultHeaderRenderer, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Scheme'), + PopupMenuButton( + itemBuilder: (BuildContext context) { + return [ + _buildPopupMenuFilterEntry(controller, FilterOption.http), + _buildPopupMenuFilterEntry(controller, FilterOption.custom), + ]; + }, + child: Icon( + Icons.arrow_drop_down, + size: actionsIconSize, + ), + ), + ], + ); + } + + @override + Widget build( + BuildContext context, + LinkData dataObject, { + bool isRowSelected = false, + VoidCallback? onPressed, + }) { + return Text(getValue(dataObject)); + } + + @override + String getValue(LinkData dataObject) => dataObject.scheme.join(', '); } -class OSColumn extends ColumnData { - OSColumn() +class OSColumn extends ColumnData + implements ColumnRenderer, ColumnHeaderRenderer { + OSColumn(this.controller) : super( 'OS', fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), ); + DeepLinksController controller; + + @override + Widget? buildHeader( + BuildContext context, + Widget Function() defaultHeaderRenderer, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('OS'), + PopupMenuButton( + itemBuilder: (BuildContext context) { + return [ + _buildPopupMenuFilterEntry(controller, FilterOption.android), + _buildPopupMenuFilterEntry(controller, FilterOption.ios), + ]; + }, + child: Icon( + Icons.arrow_drop_down, + size: actionsIconSize, + ), + ), + ], + ); + } + @override - String getValue(LinkData dataObject) => dataObject.os.join(','); + Widget build( + BuildContext context, + LinkData dataObject, { + bool isRowSelected = false, + VoidCallback? onPressed, + }) { + return Text(getValue(dataObject)); + } + + @override + String getValue(LinkData dataObject) => + dataObject.os.map((e) => e.description).toList().join(', '); } class StatusColumn extends ColumnData - implements ColumnRenderer { - StatusColumn() + implements ColumnRenderer, ColumnHeaderRenderer { + StatusColumn(this.controller, this.viewType) : super( 'Status', fixedWidthPx: scaleByFontFactor(kDeeplinkTableCellDefaultWidth), ); + DeepLinksController controller; + + TableViewType viewType; + @override String getValue(LinkData dataObject) { - if (dataObject.domainError) { + if (dataObject.domainErrors.isNotEmpty) { return 'Failed domain checks'; } else if (dataObject.pathError) { return 'Failed path checks'; @@ -174,6 +404,40 @@ class StatusColumn extends ColumnData } } + @override + Widget? buildHeader( + BuildContext context, + Widget Function() defaultHeaderRenderer, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Status'), + PopupMenuButton( + itemBuilder: (BuildContext context) { + return [ + if (viewType != TableViewType.domainView) + _buildPopupMenuFilterEntry( + controller, + FilterOption.failedPathCheck, + ), + if (viewType != TableViewType.pathView) + _buildPopupMenuFilterEntry( + controller, + FilterOption.failedDomainCheck, + ), + _buildPopupMenuFilterEntry(controller, FilterOption.noIssue), + ]; + }, + child: Icon( + Icons.arrow_drop_down, + size: actionsIconSize, + ), + ), + ], + ); + } + @override Widget build( BuildContext context, @@ -181,7 +445,7 @@ class StatusColumn extends ColumnData bool isRowSelected = false, VoidCallback? onPressed, }) { - if (dataObject.domainError || dataObject.pathError) { + if (dataObject.domainErrors.isNotEmpty || dataObject.pathError) { return Text( getValue(dataObject), overflow: TextOverflow.ellipsis, @@ -197,7 +461,7 @@ class StatusColumn extends ColumnData } } -// TODO: implement this column. +// TODO: Implement this column. class NavigationColumn extends ColumnData implements ColumnRenderer { NavigationColumn() @@ -220,6 +484,70 @@ class NavigationColumn extends ColumnData } } +PopupMenuEntry _buildPopupMenuFilterEntry( + DeepLinksController controller, + FilterOption filterOption, +) { + return PopupMenuItem( + value: filterOption, + child: Row( + children: [ + ValueListenableBuilder( + valueListenable: controller.displayOptionsNotifier, + builder: (context, option, _) => Checkbox( + value: option.filters.contains(filterOption), + onChanged: (bool? checked) => controller.updateDisplayOptions( + removedFilter: checked! ? null : filterOption, + addedFilter: checked ? filterOption : null, + ), + ), + ), + Text(filterOption.description), + ], + ), + ); +} + +List> _buildPopupMenuSortingEntries( + DeepLinksController controller, { + required bool isPath, +}) { + return [ + _buildPopupMenuSortingEntry( + controller, + SortingOption.errorOnTop, + isPath: isPath, + ), + _buildPopupMenuSortingEntry( + controller, + SortingOption.aToZ, + isPath: isPath, + ), + _buildPopupMenuSortingEntry( + controller, + SortingOption.zToA, + isPath: isPath, + ), + ]; +} + +PopupMenuEntry _buildPopupMenuSortingEntry( + DeepLinksController controller, + SortingOption sortingOption, { + required bool isPath, +}) { + return PopupMenuItem( + onTap: () { + controller.updateDisplayOptions( + pathSortingOption: isPath ? sortingOption : null, + domainSortingOption: isPath ? null : sortingOption, + ); + }, + value: sortingOption, + child: Text(sortingOption.description), + ); +} + class FlutterProject { FlutterProject({ required this.path, @@ -228,3 +556,32 @@ class FlutterProject { final String path; final List androidVariants; } + +int _compareLinkData( + LinkData a, + LinkData b, { + SortingOption? sortingOption, + required bool compareDomain, +}) { + if (sortingOption == null) return 0; + + switch (sortingOption) { + case SortingOption.errorOnTop: + if (compareDomain) { + if (a.domainErrors.isNotEmpty) return 1; + if (b.domainErrors.isNotEmpty) return -1; + } else { + if (a.pathError) return 1; + if (b.pathError) return -1; + } + return 0; + case SortingOption.aToZ: + if (compareDomain) return a.domain.compareTo(b.domain); + + return a.path.compareTo(b.path); + case SortingOption.zToA: + if (compareDomain) return b.domain.compareTo(a.domain); + + return b.path.compareTo(a.path); + } +} diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart index b00f7f383ce..4e8fb23b941 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart @@ -13,12 +13,6 @@ import 'deep_links_controller.dart'; import 'deep_links_model.dart'; import 'select_project_view.dart'; -enum TableViewType { - domainView, - pathView, - singleUrlView, -} - class DeepLinksScreen extends Screen { DeepLinksScreen() : super.fromMetaData(ScreenMetaData.deepLinks); diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart index 5595652a17d..325d181d91c 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/fake_data.dart @@ -20,7 +20,7 @@ final allLinkDatas = [ os: [PlatformOS.android, PlatformOS.ios], domain: 'm.shopping.com', path: path, - domainError: true, + domainErrors: [DomainError.existence], pathError: path.contains('shoe'), ), for (var path in paths) 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 new file mode 100644 index 00000000000..b670f52cb1f --- /dev/null +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart @@ -0,0 +1,368 @@ +// Copyright 2023 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../shared/common_widgets.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'; + +class ValidationDetailView extends StatelessWidget { + const ValidationDetailView({ + super.key, + required this.linkData, + required this.viewType, + required this.controller, + }); + + final LinkData linkData; + final TableViewType viewType; + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + ValidationDetailHeader(viewType: viewType, controller: controller), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: largeSpacing, + vertical: defaultSpacing, + ), + child: Column( + 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' + ' file on your website. App checks are done for the intent filters in' + ' the manifest and info.plist file, routing issues, URL format, etc.', + style: Theme.of(context).subtleTextStyle, + ), + if (viewType == TableViewType.domainView || + viewType == TableViewType.singleUrlView) + _DomainCheckTable( + controller: controller, + ), + if (viewType == TableViewType.pathView || + 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 (viewType == TableViewType.domainView) + _DomainAssociatedLinksPanel(controller: controller), + ], + ), + ), + ], + ); + } +} + +class ValidationDetailHeader extends StatelessWidget { + const ValidationDetailHeader({ + super.key, + required this.viewType, + required this.controller, + }); + + final TableViewType viewType; + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + return OutlineDecoration( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: defaultSpacing), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + viewType == TableViewType.domainView + ? 'Selected domain validation details' + : 'Selected Deep link validation details', + style: Theme.of(context).textTheme.titleSmall, + ), + IconButton( + onPressed: () => + controller.updateDisplayOptions(showSplitScreen: false), + icon: const Icon(Icons.close), + ), + ], + ), + ), + ); + } +} + +class _DomainCheckTable extends StatelessWidget { + const _DomainCheckTable({ + required this.controller, + }); + + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final linkData = controller.selectedLink.value!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: intermediateSpacing), + Text('Domain check', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: denseSpacing), + DataTable( + headingRowColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.deeplinkTableHeaderColor, + ), + dataRowColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.alternatingBackgroundColor2, + ), + columns: const [ + DataColumn(label: Text('OS')), + DataColumn(label: Text('Issue type')), + DataColumn(label: Text('Status')), + ], + dataRowMinHeight: defaultRowHeight, + dataRowMaxHeight: defaultRowHeight, + rows: [ + if (linkData.os.contains(PlatformOS.android)) + DataRow( + cells: [ + const DataCell(Text('Android')), + const DataCell(Text('Digital assets link file')), + DataCell( + linkData.domainErrors.isNotEmpty + ? Text( + 'Check failed', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ) + : Text( + 'No issues found', + style: TextStyle( + color: Theme.of(context).colorScheme.green, + ), + ), + ), + ], + ), + if (linkData.os.contains(PlatformOS.ios)) + DataRow( + cells: [ + const DataCell(Text('iOS')), + const DataCell(Text('Apple-App-Site-Association file')), + DataCell( + Text( + 'No issues found', + style: + TextStyle(color: Theme.of(context).colorScheme.green), + ), + ), + ], + ), + ], + ), + if (linkData.domainErrors.isNotEmpty) + _DomainFixPanel(controller: controller), + ], + ); + } +} + +class _DomainFixPanel extends StatelessWidget { + const _DomainFixPanel({ + required this.controller, + }); + + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final linkData = controller.selectedLink.value!; + return 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.', + style: Theme.of(context).subtleTextStyle, + ), + Text( + 'Update and publish recommend Digital Asset Links JSON file below to this location: ', + style: Theme.of(context).subtleTextStyle, + ), + 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, + ), + ), + ), + ), + ), + ), + Card( + child: ValueListenableBuilder( + valueListenable: controller.generatedAssetLinksForSelectedLink, + builder: (_, String? generatedAssetLinks, __) => + generatedAssetLinks != null + ? Align( + alignment: Alignment.centerLeft, + child: SelectionArea( + child: Text(generatedAssetLinks), + ), + ) + : const CenteredCircularProgressIndicator(), + ), + ), + ], + ); + } +} + +class _DomainAssociatedLinksPanel extends StatelessWidget { + const _DomainAssociatedLinksPanel({ + required this.controller, + }); + + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final linkData = controller.selectedLink.value!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Associated deep link URL', + style: Theme.of(context).textTheme.titleSmall, + ), + Card( + color: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(denseSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: linkData.associatedPath + .map( + (path) => Padding( + padding: const EdgeInsets.symmetric( + vertical: denseRowSpacing, + ), + child: Row( + children: [ + if (linkData.domainErrors.isNotEmpty) + Icon( + Icons.error, + color: Theme.of(context).colorScheme.error, + size: defaultIconSize, + ), + const SizedBox(width: denseSpacing), + Text(path), + ], + ), + ), + ) + .toList(), + ), + ), + ), + ], + ); + } +} + +class _PathCheckTable extends StatelessWidget { + @override + Widget build(BuildContext context) { + final notAvailableCell = DataCell( + Text( + 'Not available', + style: TextStyle( + color: Theme.of(context).colorScheme.deeplinkUnavailableColor, + ), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: intermediateSpacing), + Text( + 'Path check (coming soon)', + style: Theme.of(context).textTheme.titleSmall, + ), + Opacity( + opacity: 0.5, + child: DataTable( + headingRowColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.deeplinkTableHeaderColor, + ), + dataRowColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.alternatingBackgroundColor2, + ), + columns: const [ + DataColumn(label: Text('OS')), + DataColumn(label: Text('Issue type')), + DataColumn(label: Text('Status')), + ], + rows: [ + DataRow( + cells: [ + const DataCell(Text('Android')), + const DataCell(Text('Intent filter')), + notAvailableCell, + ], + ), + DataRow( + cells: [ + const DataCell(Text('iOS')), + const DataCell(Text('Associated domain')), + notAvailableCell, + ], + ), + DataRow( + cells: [ + const DataCell(Text('Android, iOS')), + const DataCell(Text('URL format')), + notAvailableCell, + ], + ), + DataRow( + cells: [ + const DataCell(Text('Android, iOS')), + const DataCell(Text('Routing')), + notAvailableCell, + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/shared/table/table.dart b/packages/devtools_app/lib/src/shared/table/table.dart index b1ba673d49f..15426c9b729 100644 --- a/packages/devtools_app/lib/src/shared/table/table.dart +++ b/packages/devtools_app/lib/src/shared/table/table.dart @@ -113,6 +113,7 @@ class FlatTable extends StatefulWidget { this.includeColumnGroupHeaders = true, this.tallHeaders = false, this.sizeColumnsToFit = true, + this.headerColor, ValueNotifier? selectionNotifier, }) : selectionNotifier = selectionNotifier ?? ValueNotifier(null), super(key: key); @@ -151,6 +152,11 @@ class FlatTable extends StatefulWidget { /// support multiline text. final bool tallHeaders; + /// The background color of the header. + /// + /// If null, defaults to `Theme.of(context).canvasColor`. + final Color? headerColor; + /// Data set to show as rows in this table. final List data; @@ -319,6 +325,7 @@ class FlatTableState extends State> with AutoDisposeMixin { rowItemExtent: defaultRowHeight, preserveVerticalScrollPosition: widget.preserveVerticalScrollPosition, tallHeaders: widget.tallHeaders, + headerColor: widget.headerColor, ); if (widget.sizeColumnsToFit || tableController.columnWidths == null) { return LayoutBuilder( @@ -424,6 +431,7 @@ class TreeTable> extends StatefulWidget { this.preserveVerticalScrollPosition = false, this.displayTreeGuidelines = false, this.tallHeaders = false, + this.headerColor, ValueNotifier>? selectionNotifier, }) : selectionNotifier = selectionNotifier ?? ValueNotifier>(Selection.empty()), @@ -498,6 +506,11 @@ class TreeTable> extends StatefulWidget { /// support multiline text. final bool tallHeaders; + /// The background color of the header. + /// + /// If null, defaults to `Theme.of(context).canvasColor`. + final Color? headerColor; + @override TreeTableState createState() => TreeTableState(); } @@ -658,6 +671,7 @@ class TreeTableState> extends State> selectionNotifier: widget.selectionNotifier, preserveVerticalScrollPosition: widget.preserveVerticalScrollPosition, tallHeaders: widget.tallHeaders, + headerColor: widget.headerColor, ); } @@ -837,6 +851,7 @@ class _Table extends StatefulWidget { this.activeSearchMatchNotifier, this.rowItemExtent, this.tallHeaders = false, + this.headerColor, }) : super(key: key); final TableControllerBase tableController; @@ -850,6 +865,7 @@ class _Table extends StatefulWidget { final ValueListenable? activeSearchMatchNotifier; final bool preserveVerticalScrollPosition; final bool tallHeaders; + final Color? headerColor; @override _TableState createState() => _TableState(); @@ -1048,6 +1064,7 @@ class _TableState extends State<_Table> with AutoDisposeMixin { widget.tableController.secondarySortColumn, onSortChanged: widget.tableController.sortDataAndNotify, tall: widget.tallHeaders, + backgroundColor: widget.headerColor, ), TableRow.tableColumnHeader( key: const Key('Table header'), @@ -1061,6 +1078,7 @@ class _TableState extends State<_Table> with AutoDisposeMixin { secondarySortColumn: widget.tableController.secondarySortColumn, onSortChanged: widget.tableController.sortDataAndNotify, tall: widget.tallHeaders, + backgroundColor: widget.headerColor, ), if (pinnedData.isNotEmpty) ...[ SizedBox( @@ -1195,13 +1213,13 @@ class TableRow extends StatefulWidget { this.secondarySortColumn, this.onPressed, this.tall = false, + this.backgroundColor, }) : node = null, isExpanded = false, isExpandable = false, isSelected = false, expandableColumn = null, isShown = true, - backgroundColor = null, searchMatchesNotifier = null, activeSearchMatchNotifier = null, displayTreeGuidelines = false, @@ -1221,6 +1239,7 @@ class TableRow extends StatefulWidget { this.secondarySortColumn, this.onPressed, this.tall = false, + this.backgroundColor, }) : node = null, isExpanded = false, isExpandable = false, @@ -1228,7 +1247,6 @@ class TableRow extends StatefulWidget { expandableColumn = null, columns = const [], isShown = true, - backgroundColor = null, searchMatchesNotifier = null, activeSearchMatchNotifier = null, displayTreeGuidelines = false, diff --git a/packages/devtools_app/lib/src/shared/ui/colors.dart b/packages/devtools_app/lib/src/shared/ui/colors.dart index 39f3b7ad156..473aeccae35 100644 --- a/packages/devtools_app/lib/src/shared/ui/colors.dart +++ b/packages/devtools_app/lib/src/shared/ui/colors.dart @@ -148,4 +148,6 @@ extension DevToolsColorExtension on ColorScheme { Color get green => const Color.fromARGB(255, 156, 233, 195); Color get overlayShadowColor => const Color.fromRGBO(0, 0, 0, 0.5); + Color get deeplinkUnavailableColor => const Color(0xFFFE7C04); + Color get deeplinkTableHeaderColor => Colors.black; } 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 52c207d1403..b28a2792044 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/validation_details_view.dart'; import 'package:devtools_app/src/shared/directory_picker.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; @@ -40,14 +41,14 @@ void main() { ), ); deferredLoadingSupportEnabled = true; - await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); expect(find.byType(DeepLinkPage), findsOneWidget); } group('DeepLinkScreen', () { setUp(() { screen = DeepLinksScreen(); - deepLinksController = DeepLinksController(); + deepLinksController = DeepLinksTestController(); }); testWidgets('builds its tab', (WidgetTester tester) async { @@ -75,18 +76,11 @@ void main() { ); testWidgetsWithWindowSize( - 'builds deeplink list page', + 'builds deeplink list page with no links', windowSize, (WidgetTester tester) async { deepLinksController.selectedProject.value = FlutterProject(path: '/abc', androidVariants: ['debug', 'release']); - deepLinksController.linkDatasNotifier.value = [ - LinkData( - domain: 'www.google.com', - path: '/', - os: [PlatformOS.android], - ), - ]; await pumpDeepLinkScreen( tester, controller: deepLinksController, @@ -94,7 +88,45 @@ void main() { expect(find.byType(DeepLinkPage), findsOneWidget); expect(find.byType(DeepLinkListView), findsOneWidget); + expect(find.byType(CenteredCircularProgressIndicator), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'builds deeplink list page with links and split screen', + windowSize, + (WidgetTester tester) async { + final deepLinksController = DeepLinksTestController(); + + deepLinksController.selectedProject.value = + FlutterProject(path: '/abc', androidVariants: ['debug', 'release']); + final linkData = LinkData( + domain: 'www.google.com', + path: '/', + os: [PlatformOS.android], + ); + deepLinksController.allLinkDatasNotifier.value = [linkData]; + deepLinksController.displayOptionsNotifier.value = + DisplayOptions(showSplitScreen: true); + deepLinksController.selectedLink.value = linkData; + + await pumpDeepLinkScreen( + tester, + controller: deepLinksController, + ); + + expect(find.byType(DeepLinkPage), findsOneWidget); + expect(find.byType(DeepLinkListView), findsOneWidget); + expect(find.byType(ValidationDetailView), findsOneWidget); }, ); }); } + +//TODO(hangyujin): Add more unit tests. +class DeepLinksTestController extends DeepLinksController { + @override + Future validateLinks() async { + displayLinkDatasNotifier.value = allLinkDatasNotifier.value; + } +}