Wires up deeplink validation tool backend (#6536)
diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart index 7816f3f..9d91886 100644 --- a/packages/devtools_app/lib/devtools_app.dart +++ b/packages/devtools_app/lib/devtools_app.dart
@@ -19,6 +19,8 @@ export 'src/screens/debugger/program_explorer_model.dart'; export 'src/screens/debugger/span_parser.dart'; export 'src/screens/debugger/syntax_highlighter.dart'; +export 'src/screens/deep_link_validation/deep_links_controller.dart'; +export 'src/screens/deep_link_validation/deep_links_screen.dart'; export 'src/screens/inspector/inspector_controller.dart'; export 'src/screens/inspector/inspector_screen.dart'; export 'src/screens/inspector/inspector_tree_controller.dart';
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 new file mode 100644 index 0000000..8f1508c --- /dev/null +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart
@@ -0,0 +1,384 @@ +// 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 'dart:math'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../shared/common_widgets.dart'; +import '../../shared/primitives/utils.dart'; +import '../../shared/table/table.dart'; +import '../../shared/table/table_data.dart'; +import '../../shared/ui/colors.dart'; +import '../../shared/utils.dart'; +import 'deep_links_controller.dart'; +import 'deep_links_model.dart'; + +enum TableViewType { + domainView, + pathView, + singleUrlView, +} + +/// A view that display all deep links for the app. +class DeepLinkListView extends StatefulWidget { + const DeepLinkListView({super.key}); + + @override + State<DeepLinkListView> createState() => _DeepLinkListViewState(); +} + +class _DeepLinkListViewState extends State<DeepLinkListView> + with ProvidedControllerMixin<DeepLinksController, DeepLinkListView> { + List<String> get androidVariants => + controller.selectedProject.value!.androidVariants; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + initController(); + callWhenControllerReady((_) { + int releaseVariantIndex = controller + .selectedProject.value!.androidVariants + .indexWhere((variant) => variant.toLowerCase().contains('release')); + // If not found, default to 0. + releaseVariantIndex = max(releaseVariantIndex, 0); + controller.selectedVariantIndex.value = releaseVariantIndex; + }); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: TableViewType.values.length, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DeepLinkListViewTopPanel(), + SizedBox(height: denseSpacing), + Expanded(child: _DeepLinkListViewMainPanel()), + ], + ), + ); + } +} + +class _DeepLinkListViewMainPanel extends StatelessWidget { + const _DeepLinkListViewMainPanel(); + + @override + Widget build(BuildContext context) { + final controller = Provider.of<DeepLinksController>(context); + return ValueListenableBuilder<List<LinkData>?>( + 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; + }, + ), + ), + ], + ), + 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<bool>( + 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, + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +class _DataTableWithValidationDetails extends StatelessWidget { + const _DataTableWithValidationDetails({ + required this.linkDatas, + required this.tableView, + required this.controller, + required this.showSpitScreen, + }); + final List<LinkData> 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<LinkData?>( + valueListenable: controller.selectedLink, + builder: (context, selectedLink, _) => _ValidationDetailScreen( + tableView: tableView, + linkData: selectedLink!, + controller: controller, + ), + ), + ), + ], + ); + } +} + +class _DataTable extends StatelessWidget { + const _DataTable({ + required this.linkDatas, + required this.tableView, + required this.controller, + }); + final List<LinkData> linkDatas; + final TableViewType tableView; + final DeepLinksController controller; + + @override + Widget build(BuildContext context) { + final ColumnData<LinkData> domain = DomainColumn(); + final ColumnData<LinkData> path = PathColumn(); + + return FlatTable( + keyFactory: (node) => ValueKey(node.toString), + data: linkDatas, + dataKey: 'deep-links', + autoScrollContent: true, + columns: <ColumnData>[ + 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; + + @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)), + ], + ), + ); + } +} + +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<DeepLinksController>(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: ValueListenableBuilder( + valueListenable: controller.selectedVariantIndex, + builder: (_, value, __) { + return _AndroidVariantDropdown( + androidVariants: + controller.selectedProject.value!.androidVariants, + index: value, + onVariantIndexSelected: (index) { + controller.selectedVariantIndex.value = index; + }, + ); + }, + ), + ), + ], + ); + } +} + +class _AndroidVariantDropdown extends StatelessWidget { + const _AndroidVariantDropdown({ + required this.androidVariants, + required this.index, + required this.onVariantIndexSelected, + }); + + final List<String> androidVariants; + final int index; + final ValueChanged<int> onVariantIndexSelected; + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Text('Android Variant:'), + RoundedDropDownButton<int>( + value: index, + items: [ + for (int i = 0; i < androidVariants.length; i++) + DropdownMenuItem<int>( + value: i, + child: Text(androidVariants[i]), + ), + ], + onChanged: (int? index) { + onVariantIndexSelected(index!); + }, + ), + ], + ); + } +}
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 961e82b..f742733 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
@@ -2,17 +2,26 @@ // 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_shared/devtools_deeplink.dart'; import 'package:flutter/foundation.dart'; +import '../../shared/config_specific/server/server.dart' as server; import 'deep_links_model.dart'; -import 'fake_data.dart'; + +typedef _DomainAndPath = ({String domain, String path}); class DeepLinksController { + DeepLinksController() { + selectedVariantIndex.addListener(_handleSelectedVariantIndexChanged); + } + bool get showSpitScreen => showSpitScreenNotifier.value; List<LinkData> get getLinkDatasByPath { final linkDatasByPath = <String, LinkData>{}; - for (var linkData in linkDatasNotifier.value) { + for (var linkData in linkDatasNotifier.value!) { linkDatasByPath[linkData.path] = linkData; } return linkDatasByPath.values.toList(); @@ -20,14 +29,61 @@ List<LinkData> get getLinkDatasByDomain { final linkDatasByDomain = <String, LinkData>{}; - for (var linkData in linkDatasNotifier.value) { + for (var linkData in linkDatasNotifier.value!) { linkDatasByDomain[linkData.domain] = linkData; } return linkDatasByDomain.values.toList(); } + final Map<int, AppLinkSettings> _androidAppLinks = <int, AppLinkSettings>{}; + + late final selectedVariantIndex = ValueNotifier<int>(0); + void _handleSelectedVariantIndexChanged() { + linkDatasNotifier.value = null; + unawaited(_loadAndroidAppLinks()); + } + + Future<void> _loadAndroidAppLinks() async { + if (!_androidAppLinks.containsKey(selectedVariantIndex.value)) { + final variant = + selectedProject.value!.androidVariants[selectedVariantIndex.value]; + final result = await server.requestAndroidAppLinkSettings( + selectedProject.value!.path, + buildVariant: variant, + ); + _androidAppLinks[selectedVariantIndex.value] = result; + } + _updateLinks(); + } + + List<LinkData> get _allLinkDatas { + final appLinks = _androidAppLinks[selectedVariantIndex.value]?.deeplinks; + if (appLinks == null) { + return const <LinkData>[]; + } + final domainPathToScheme = <_DomainAndPath, Set<String>>{}; + for (final appLink in appLinks) { + final schemes = domainPathToScheme.putIfAbsent( + (domain: appLink.host, path: appLink.path), + () => <String>{}, + ); + schemes.add(appLink.scheme); + } + return domainPathToScheme.entries + .map( + (entry) => LinkData( + domain: entry.key.domain, + path: entry.key.path, + os: [PlatformOS.android], + scheme: entry.value.toList(), + ), + ) + .toList(); + } + + final selectedProject = ValueNotifier<FlutterProject?>(null); final selectedLink = ValueNotifier<LinkData?>(null); - final linkDatasNotifier = ValueNotifier<List<LinkData>>(allLinkDatas); + final linkDatasNotifier = ValueNotifier<List<LinkData>?>(null); final showSpitScreenNotifier = ValueNotifier<bool>(false); final _searchContentNotifier = ValueNotifier<String>(''); @@ -35,7 +91,7 @@ void _updateLinks() { final searchContent = _searchContentNotifier.value; final List<LinkData> linkDatas = searchContent.isNotEmpty - ? allLinkDatas + ? _allLinkDatas .where( (linkData) => linkData.matchesSearchToken( RegExp( @@ -45,8 +101,7 @@ ), ) .toList() - : allLinkDatas; - + : _allLinkDatas; linkDatasNotifier.value = 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 8c47c6a..5ef2d74 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
@@ -13,6 +13,11 @@ const kDeeplinkTableCellDefaultWidth = 200.0; +enum PlatformOS { + android, + ios, +} + /// Contains all data relevant to a deep link. class LinkData with SearchableDataMixin { LinkData({ @@ -26,7 +31,7 @@ final String path; final String domain; - final List<String> os; + final List<PlatformOS> os; final List<String> scheme; final bool domainError; final bool pathError; @@ -214,3 +219,12 @@ return const Icon(Icons.arrow_forward); } } + +class FlutterProject { + FlutterProject({ + required this.path, + required this.androidVariants, + }); + final String path; + final List<String> androidVariants; +}
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 0dc00c2..b723885 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
@@ -2,19 +2,14 @@ // 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:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; -import '../../shared/common_widgets.dart'; -import '../../shared/primitives/utils.dart'; import '../../shared/screen.dart'; -import '../../shared/table/table.dart'; -import '../../shared/table/table_data.dart'; -import '../../shared/ui/colors.dart'; import '../../shared/utils.dart'; +import 'deep_link_list_view.dart'; import 'deep_links_controller.dart'; import 'deep_links_model.dart'; +import 'select_project_view.dart'; enum TableViewType { domainView, @@ -45,10 +40,7 @@ } class _DeepLinkPageState extends State<DeepLinkPage> - with - AutoDisposeMixin, - SingleTickerProviderStateMixin, - ProvidedControllerMixin<DeepLinksController, DeepLinkPage> { + with ProvidedControllerMixin<DeepLinksController, DeepLinkPage> { @override void didChangeDependencies() { super.didChangeDependencies(); @@ -57,249 +49,13 @@ @override Widget build(BuildContext context) { - return DefaultTabController( - length: TableViewType.values.length, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - 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; - }, - ), - ), - ], - ), - 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<List<LinkData>>( - valueListenable: controller.linkDatasNotifier, - builder: (context, linkDatas, _) => ValueListenableBuilder<bool>( - 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, - ), - ], - ), - ), - ), - ), - ], - ), - ); - } -} - -class _DataTableWithValidationDetails extends StatelessWidget { - const _DataTableWithValidationDetails({ - required this.linkDatas, - required this.tableView, - required this.controller, - required this.showSpitScreen, - }); - final List<LinkData> 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<LinkData?>( - valueListenable: controller.selectedLink, - builder: (context, selectedLink, _) => _ValidationDetailScreen( - tableView: tableView, - linkData: selectedLink!, - controller: controller, - ), - ), - ), - ], - ); - } -} - -class _DataTable extends StatelessWidget { - const _DataTable({ - required this.linkDatas, - required this.tableView, - required this.controller, - }); - final List<LinkData> linkDatas; - final TableViewType tableView; - final DeepLinksController controller; - - @override - Widget build(BuildContext context) { - final ColumnData<LinkData> domain = DomainColumn(); - final ColumnData<LinkData> path = PathColumn(); - - return FlatTable( - keyFactory: (node) => ValueKey(node.toString), - data: linkDatas, - dataKey: 'deep-links', - autoScrollContent: true, - columns: <ColumnData>[ - 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; - - @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 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).textTheme.bodySmall, - ), - const Text('Domain check'), - Expanded(child: _DomainCheckTable(linkData: linkData)), - ], - ), - ); - } -} - -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('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('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), - ), - ), - ], - ), - ], + return ValueListenableBuilder( + valueListenable: controller.selectedProject, + builder: (_, FlutterProject? project, __) { + return project == null + ? const SelectProjectView() + : const DeepLinkListView(); + }, ); } }
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 2e16a18..5595652 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
@@ -17,7 +17,7 @@ final allLinkDatas = <LinkData>[ for (var path in paths) LinkData( - os: ['Android', 'iOS'], + os: [PlatformOS.android, PlatformOS.ios], domain: 'm.shopping.com', path: path, domainError: true, @@ -25,14 +25,14 @@ ), for (var path in paths) LinkData( - os: ['iOS'], + os: [PlatformOS.ios], domain: 'm.french.shopping.com', path: path, pathError: path.contains('shoe'), ), for (var path in paths) LinkData( - os: ['Android'], + os: [PlatformOS.android], domain: 'm.chinese.shopping.com', path: path, pathError: path.contains('shoe'),
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/select_project_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/select_project_view.dart new file mode 100644 index 0000000..9d05e20 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/select_project_view.dart
@@ -0,0 +1,97 @@ +// 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/config_specific/server/server.dart' as server; +import '../../shared/directory_picker.dart'; +import '../../shared/utils.dart'; +import 'deep_links_controller.dart'; +import 'deep_links_model.dart'; + +/// A view for selecting a Flutter project. +class SelectProjectView extends StatefulWidget { + const SelectProjectView({super.key}); + + @override + State<SelectProjectView> createState() => _SelectProjectViewState(); +} + +class _SelectProjectViewState extends State<SelectProjectView> + with ProvidedControllerMixin<DeepLinksController, SelectProjectView> { + static const _kMessageSize = 24.0; + bool _retrievingFlutterProject = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!initController()) return; + } + + void _handleDirectoryPicked(String directory) async { + setState(() { + _retrievingFlutterProject = true; + }); + final List<String> androidVariants = + await server.requestAndroidBuildVariants(directory); + if (!context.mounted) { + return; + } + if (androidVariants.isEmpty) { + await showDialog( + context: context, + builder: (_) { + return const AlertDialog( + title: Text('You selected a non Flutter project'), + content: Text( + 'Seems you selected a non Flutter project. If it is not intended, please reselect a Flutter project.', + ), + actions: [ + DialogCloseButton(), + ], + ); + }, + ); + } else { + controller.selectedProject.value = + FlutterProject(path: directory, androidVariants: androidVariants); + } + setState(() { + _retrievingFlutterProject = false; + }); + } + + @override + Widget build(BuildContext context) { + Widget? child; + if (_retrievingFlutterProject) { + child = const CenteredCircularProgressIndicator(size: _kMessageSize); + } else { + child = Text( + 'Pick a flutter project from your local file to check all deep links status', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).textTheme.displayLarge!.color, + ), + ); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: child, + ), + DirectoryPicker( + onDirectoryPicked: _handleDirectoryPicked, + enabled: !_retrievingFlutterProject, + ), + ], + ), + ); + } +}
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index 4546930..82040cc 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -32,6 +32,7 @@ final vmTools = ScreenMetaData.vmTools.id; const console = 'console'; final simple = ScreenMetaData.simple.id; +final deeplink = ScreenMetaData.deepLinks.id; // GA events not associated with a any screen e.g., hotReload, hotRestart, etc const devToolsMain = 'main';
diff --git a/packages/devtools_app/lib/src/shared/directory_picker.dart b/packages/devtools_app/lib/src/shared/directory_picker.dart new file mode 100644 index 0000000..1f84aa1 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/directory_picker.dart
@@ -0,0 +1,67 @@ +// 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'; + +class DirectoryPicker extends StatefulWidget { + const DirectoryPicker({ + required this.onDirectoryPicked, + this.enabled = true, + super.key, + }); + + final bool enabled; + + final ValueChanged<String> onDirectoryPicked; + + @override + State<DirectoryPicker> createState() => _DirectoryPickerState(); +} + +class _DirectoryPickerState extends State<DirectoryPicker> { + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final rowHeight = defaultButtonHeight; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + Flexible( + flex: 4, + fit: FlexFit.tight, + child: RoundedOutlinedBorder( + child: Container( + height: rowHeight, + padding: const EdgeInsets.symmetric(horizontal: defaultSpacing), + child: TextField( + controller: controller, + enabled: widget.enabled, + onSubmitted: (String path) { + widget.onDirectoryPicked(path.trim()); + }, + decoration: const InputDecoration( + hintText: 'Enter path to a Flutter project here', + ), + style: TextStyle( + color: Theme.of(context).textTheme.displayLarge!.color, + ), + textAlign: TextAlign.left, + ), + ), + ), + ), + const Spacer(), + ], + ); + } +}
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 new file mode 100644 index 0000000..52c207d --- /dev/null +++ b/packages/devtools_app/test/deep_link_vlidation/deep_links_screen_test.dart
@@ -0,0 +1,100 @@ +// Copyright 2020 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/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/shared/directory_picker.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() { + setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(IdeTheme, IdeTheme()); + setGlobal(NotificationService, NotificationService()); + }); + + late DeepLinksScreen screen; + late DeepLinksController deepLinksController; + + const windowSize = Size(2560.0, 1338.0); + + Future<void> pumpDeepLinkScreen( + WidgetTester tester, { + required DeepLinksController controller, + }) async { + await tester.pumpWidget( + wrapWithControllers( + const DeepLinkPage(), + deepLink: controller, + ), + ); + deferredLoadingSupportEnabled = true; + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(DeepLinkPage), findsOneWidget); + } + + group('DeepLinkScreen', () { + setUp(() { + screen = DeepLinksScreen(); + deepLinksController = DeepLinksController(); + }); + + testWidgets('builds its tab', (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithControllers( + Builder(builder: screen.buildTab), + deepLink: deepLinksController, + ), + ); + expect(find.text('Deep Links'), findsOneWidget); + }); + + testWidgetsWithWindowSize( + 'builds initial content', + windowSize, + (WidgetTester tester) async { + await pumpDeepLinkScreen( + tester, + controller: deepLinksController, + ); + + expect(find.byType(DeepLinkPage), findsOneWidget); + expect(find.byType(DirectoryPicker), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'builds deeplink list page', + 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, + ); + + expect(find.byType(DeepLinkPage), findsOneWidget); + expect(find.byType(DeepLinkListView), findsOneWidget); + }, + ); + }); +}
diff --git a/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart b/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart index 73d3e40..9220df6 100644 --- a/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart +++ b/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart
@@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// This file handles json object. +// ignore_for_file: avoid-dynamic + import 'dart:convert'; /// The app link related settings of a Android build of a Flutter project. @@ -12,8 +15,9 @@ final jsonObject = jsonDecode(json); return AppLinkSettings._( jsonObject[_kApplicationIdKey] as String, - jsonObject[_kDeeplinksKey] - .map<AndroidDeeplink>(AndroidDeeplink._fromJsonObject), + (jsonObject[_kDeeplinksKey] as List<dynamic>) + .map<AndroidDeeplink>(AndroidDeeplink._fromJsonObject) + .toList(), ); } @@ -39,11 +43,11 @@ class AndroidDeeplink { AndroidDeeplink._(this.scheme, this.host, this.path); - factory AndroidDeeplink._fromJsonObject(Map<String, Object?> object) { + factory AndroidDeeplink._fromJsonObject(dynamic json) { return AndroidDeeplink._( - object[_kSchemeKey] as String, - object[_kHostKey] as String, - object[_kPathKey] as String, + json[_kSchemeKey] as String, + json[_kHostKey] as String, + json[_kPathKey] as String, ); }
diff --git a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart index aed1e39..10c6183 100644 --- a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart +++ b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart
@@ -190,7 +190,6 @@ } class _FlutterProcessError extends Error { - /// Constructs a [GoError] _FlutterProcessError(this.message); /// The error message.
diff --git a/packages/devtools_shared/lib/src/server/server_api.dart b/packages/devtools_shared/lib/src/server/server_api.dart index 18fb7b3..528307c 100644 --- a/packages/devtools_shared/lib/src/server/server_api.dart +++ b/packages/devtools_shared/lib/src/server/server_api.dart
@@ -492,7 +492,7 @@ ) { final error = result[DeeplinkManager.kErrorField] as String?; if (error != null) { - api.serverError(error); + return api.serverError(error); } return api.getCompleted( result[DeeplinkManager.kOutputJsonField]! as String,
diff --git a/packages/devtools_test/lib/src/utils.dart b/packages/devtools_test/lib/src/utils.dart index 775f875..6f590e8 100644 --- a/packages/devtools_test/lib/src/utils.dart +++ b/packages/devtools_test/lib/src/utils.dart
@@ -17,6 +17,7 @@ final screenIds = <String>[ AppSizeScreen.id, DebuggerScreen.id, + DeepLinksScreen.id, InspectorScreen.id, LoggingScreen.id, MemoryScreen.id,
diff --git a/packages/devtools_test/lib/src/wrappers.dart b/packages/devtools_test/lib/src/wrappers.dart index 4814998..2e00c23 100644 --- a/packages/devtools_test/lib/src/wrappers.dart +++ b/packages/devtools_test/lib/src/wrappers.dart
@@ -86,6 +86,7 @@ PerformanceController? performance, ProfilerScreenController? profiler, DebuggerController? debugger, + DeepLinksController? deepLink, NetworkController? network, AppSizeController? appSize, AnalyticsController? analytics, @@ -104,6 +105,7 @@ Provider<ProfilerScreenController>.value(value: profiler), if (network != null) Provider<NetworkController>.value(value: network), if (debugger != null) Provider<DebuggerController>.value(value: debugger), + if (deepLink != null) Provider<DeepLinksController>.value(value: deepLink), if (appSize != null) Provider<AppSizeController>.value(value: appSize), if (analytics != null) Provider<AnalyticsController>.value(value: analytics),