[deeplink] add safe guards around api request (#10022)

* [deeplink] add safe guards around api request

* update

* update

* update

* update

* update

* update
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 a6130c8..2344bac 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
@@ -93,7 +93,11 @@
               message: 'Your Flutter project has no Links to verify.',
             );
           case PagePhase.analyzeErrorPage:
-            assert(controller.currentAppLinkSettings?.error != null);
+            final error = [
+              controller.currentAppLinkSettings?.error,
+              controller.currentUniversalLinkSettings?.error,
+            ].nonNulls.join('\n\n');
+            assert(error.isNotEmpty);
             return Column(
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
@@ -104,12 +108,8 @@
                 const SizedBox(height: densePadding),
                 Expanded(
                   child: Scrollbar(
-                    thumbVisibility: true,
                     child: SingleChildScrollView(
-                      child: Text(
-                        controller.currentAppLinkSettings!.error!,
-                        style: theme.errorTextStyle,
-                      ),
+                      child: Text(error, style: theme.errorTextStyle),
                     ),
                   ),
                 ),
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 992798d..0f6000f 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
@@ -663,8 +663,8 @@
   }
 
   Future<void> validateLinks() async {
-    final appLinkSettings = currentAppLinkSettings;
-    if (appLinkSettings?.error != null) {
+    if (currentAppLinkSettings?.error != null ||
+        currentUniversalLinkSettings?.error != null) {
       pagePhase.value = PagePhase.analyzeErrorPage;
       ga.select(
         gac.deeplink,
diff --git a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart
index 4151375..6c036cf 100644
--- a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart
+++ b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart
@@ -76,11 +76,14 @@
       },
     );
     final resp = await request(uri.toString());
-    if (resp?.statusOk ?? false) {
-      return UniversalLinkSettings.fromJson(resp!.body);
-    } else {
-      logWarning(resp, DeeplinkApi.iosUniversalLinkSettings);
+    if (resp != null) {
+      if (resp.statusOk) {
+        return UniversalLinkSettings.fromJson(resp.body);
+      } else {
+        logWarning(resp, DeeplinkApi.iosUniversalLinkSettings);
+        return UniversalLinkSettings.fromErrorJson(resp.body.toString());
+      }
     }
   }
-  return UniversalLinkSettings.empty;
+  return UniversalLinkSettings.error('DevTools server is not available');
 }
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 22f639d..1c05daf 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -91,6 +91,9 @@
 * Added a "Watch tutorial" link to the status line that points to the
   [deep links video tutorial](https://youtu.be/d7sZL6hIElw).
   [#9925](https://github.com/flutter/devtools/pull/9925)
+* Validated build option parameters and surfaced iOS universal link settings
+  errors in the Deep Links tool.
+  [#10022](https://github.com/flutter/devtools/pull/10022)
 
 ## VS Code sidebar updates
 
diff --git a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart
index aa503ec..35e69c9 100644
--- a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart
+++ b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart
@@ -655,5 +655,29 @@
       expect(find.text('/ios-path1'), findsOneWidget);
       expect(find.text('NOT /ios-path2'), findsOneWidget);
     });
+
+    testWidgetsWithWindowSize(
+      'surfaces iOS universal link settings error to UI',
+      windowSize,
+      (WidgetTester tester) async {
+        final deepLinksController = TestDeepLinksController();
+        const iosErrorMessage =
+            'Unknown iOS build configuration (Debug) or target (Runner).';
+
+        deepLinksController
+          ..selectedProject.value = FlutterProject(
+            path: '/abc',
+            androidVariants: ['debug', 'release'],
+            iosBuildOptions: xcodeBuildOptions,
+          )
+          ..fakeAndroidDeepLinks = [defaultAndroidDeeplink]
+          ..fakeIosError = iosErrorMessage;
+
+        await pumpDeepLinkScreen(tester, controller: deepLinksController);
+
+        expect(deepLinksController.pagePhase.value, PagePhase.analyzeErrorPage);
+        expect(find.text(iosErrorMessage), findsOneWidget);
+      },
+    );
   });
 }
diff --git a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart
index 509a0b3..2a17ff4 100644
--- a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart
+++ b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart
@@ -5,6 +5,7 @@
 import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app/src/screens/deep_link_validation/deep_links_model.dart';
 import 'package:devtools_app/src/screens/deep_link_validation/deep_links_services.dart';
+import 'package:devtools_shared/devtools_deeplink.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:http/http.dart';
 import 'package:http/testing.dart';
@@ -58,6 +59,7 @@
   bool hasAndroidDomainErrors = false;
   String iosValidationResponse = '';
   List<String> fakeIosDomains = [];
+  String? fakeIosError;
 
   late DeepLinksService _deepLinksService;
 
@@ -74,9 +76,9 @@
     androidAppLinks[selectedAndroidVariantIndex.value] = fakeAppLinkSettings(
       fakeAndroidDeepLinks,
     );
-    iosLinks[selectedIosConfigurationIndex.value] = fakeUniversalLinkSettings(
-      fakeIosDomains,
-    );
+    iosLinks[selectedIosConfigurationIndex.value] = fakeIosError != null
+        ? UniversalLinkSettings.error(fakeIosError!)
+        : fakeUniversalLinkSettings(fakeIosDomains);
 
     await super.validateLinks();
   }
diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md
index f5c4069..40ab214 100644
--- a/packages/devtools_shared/CHANGELOG.md
+++ b/packages/devtools_shared/CHANGELOG.md
@@ -7,6 +7,10 @@
 
 * Track providing package name for DevTools extensions to isolate extension enablement,
   deduplication, and asset loading.
+* Validate `rootPath`, `buildVariant`, `configuration`, and `target` parameters in
+  `DeeplinkManager`.
+* Add `UniversalLinkSettings.fromErrorJson`, `UniversalLinkSettings.error`, and
+  `UniversalLinkSettings.error` getter to surface iOS universal link settings errors.
 
 # 14.0.0
 
diff --git a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart
index 40aece3..3026bcc 100644
--- a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart
+++ b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart
@@ -8,6 +8,8 @@
 import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
 
+import 'xcode_build_options.dart';
+
 class DeeplinkManager {
   /// A regex to retrieve the json part from the stdout of Android analyzer.
   ///
@@ -32,6 +34,25 @@
   /// APIs.
   static const kOutputJsonField = 'json';
 
+  /// Cached Android build variants keyed by normalized project root path.
+  ///
+  /// Populated by [getAndroidBuildVariants] and used to validate `buildVariant`
+  /// in [getAndroidAppLinkSettings].
+  static final _androidBuildVariantsCache = <String, Set<String>>{};
+
+  /// Cached iOS Xcode build options keyed by normalized project root path.
+  ///
+  /// Populated by [getIosBuildOptions] and used to validate `configuration` and
+  /// `target` in [getIosUniversalLinkSettings].
+  static final _iosBuildOptionsCache = <String, XcodeBuildOptions>{};
+
+  /// Clears the cached Android build variants and iOS build options.
+  @visibleForTesting
+  static void clearBuildOptionsCache() {
+    _androidBuildVariantsCache.clear();
+    _iosBuildOptionsCache.clear();
+  }
+
   // TODO(https://github.com/flutter/devtools/issues/9702): Use the `DashTool`
   // and `DashEnvVar` enums and `getEnvironment()` helper directly from
   // `package:unified_analytics` once the pinned Flutter candidate SDK in this
@@ -66,8 +87,9 @@
   Future<ProcessResult> runProcess(
     String executable, {
     required List<String> arguments,
-    String? ide,
-    bool suppressAnalytics = false,
+    required String workingDirectory,
+    required String? ide,
+    required bool suppressAnalytics,
   }) {
     final environment = <String, String>{
       ...Platform.environment,
@@ -75,7 +97,12 @@
       'DASH__TOOL': ide != null ? _mapIdeToDashToolLabel(ide) : 'devtools',
     };
 
-    return Process.run(executable, arguments, environment: environment);
+    return Process.run(
+      executable,
+      arguments,
+      workingDirectory: workingDirectory,
+      environment: environment,
+    );
   }
 
   String _mapIdeToDashToolLabel(String ide) {
@@ -116,6 +143,7 @@
 
   Future<String> _runFlutterCommand(
     List<String> arguments, {
+    required String workingDirectory,
     required RegExp outputMatcher,
     String? ide,
     bool suppressAnalytics = false,
@@ -124,6 +152,7 @@
     final result = await runProcess(
       flutterPath,
       arguments: arguments,
+      workingDirectory: workingDirectory,
       ide: ide,
       suppressAnalytics: suppressAnalytics,
     );
@@ -140,10 +169,11 @@
     }
   }
 
-  Map<String, Object?> _handleRunFlutterError(
-    covariant _FlutterProcessError error,
-  ) {
-    return <String, String?>{kErrorField: error.message};
+  Map<String, Object?> _handleRunFlutterError(Object error) {
+    final message = error is _FlutterProcessError
+        ? error.message
+        : error.toString();
+    return <String, String?>{kErrorField: message};
   }
 
   Future<Map<String, Object?>> _handleReadJsonFile(String filePath) {
@@ -166,31 +196,62 @@
     String? ide,
     bool suppressAnalytics = false,
   }) {
+    final canonicalPath = path.canonicalize(rootPath);
     return _runFlutterCommand(
-      <String>['analyze', '--android', '--list-build-variants', rootPath],
+      <String>['analyze', '--android', '--list-build-variants'],
+      workingDirectory: rootPath,
       outputMatcher: _androidBuildVariantJsonRegex,
       ide: ide,
       suppressAnalytics: suppressAnalytics,
-    ).then<Map<String, Object?>>(
-      _handleJsonOutput,
-      onError: _handleRunFlutterError,
-    );
+    ).then<Map<String, Object?>>((jsonOutput) {
+      try {
+        final variants = (jsonDecode(jsonOutput) as List)
+            .cast<String>()
+            .toSet();
+        _androidBuildVariantsCache[canonicalPath] = variants;
+      } on Object catch (e) {
+        return <String, String?>{
+          kErrorField: 'Failed to parse Android build variants: $e',
+        };
+      }
+      return _handleJsonOutput(jsonOutput);
+    }, onError: _handleRunFlutterError);
   }
 
+  static const _fileIssueMessage =
+      'This should not happen; please file an issue at '
+      'https://github.com/flutter/devtools/issues.';
+
   Future<Map<String, Object?>> getAndroidAppLinkSettings({
     required String rootPath,
     required String buildVariant,
     String? ide,
     bool suppressAnalytics = false,
-  }) {
+  }) async {
+    final canonicalPath = path.canonicalize(rootPath);
+    final validVariants = _androidBuildVariantsCache[canonicalPath];
+    if (validVariants == null) {
+      return <String, String?>{
+        kErrorField:
+            'Android build variants for "$rootPath" have not been parsed yet. '
+            '$_fileIssueMessage',
+      };
+    }
+    if (!validVariants.contains(buildVariant)) {
+      return <String, String?>{
+        kErrorField:
+            'Unknown Android build variant "$buildVariant" for "$rootPath". '
+            '$_fileIssueMessage',
+      };
+    }
     return _runFlutterCommand(
       <String>[
         'analyze',
         '--android',
         '--output-app-link-settings',
         '--build-variant=$buildVariant',
-        rootPath,
       ],
+      workingDirectory: rootPath,
       outputMatcher: _outputFilePathRegex,
       ide: ide,
       suppressAnalytics: suppressAnalytics,
@@ -205,15 +266,25 @@
     String? ide,
     bool suppressAnalytics = false,
   }) {
+    final canonicalPath = path.canonicalize(rootPath);
     return _runFlutterCommand(
-      <String>['analyze', '--ios', '--list-build-options', rootPath],
+      <String>['analyze', '--ios', '--list-build-options'],
+      workingDirectory: rootPath,
       outputMatcher: _iosBuildOptionsJsonRegex,
       ide: ide,
       suppressAnalytics: suppressAnalytics,
-    ).then<Map<String, Object?>>(
-      _handleJsonOutput,
-      onError: _handleRunFlutterError,
-    );
+    ).then<Map<String, Object?>>((jsonOutput) {
+      try {
+        _iosBuildOptionsCache[canonicalPath] = XcodeBuildOptions.fromJson(
+          jsonOutput,
+        );
+      } on Object catch (e) {
+        return <String, String?>{
+          kErrorField: 'Failed to parse iOS build options: $e',
+        };
+      }
+      return _handleJsonOutput(jsonOutput);
+    }, onError: _handleRunFlutterError);
   }
 
   Future<Map<String, Object?>> getIosUniversalLinkSettings({
@@ -222,7 +293,24 @@
     required String target,
     String? ide,
     bool suppressAnalytics = false,
-  }) {
+  }) async {
+    final canonicalPath = path.canonicalize(rootPath);
+    final validOptions = _iosBuildOptionsCache[canonicalPath];
+    if (validOptions == null) {
+      return <String, String?>{
+        kErrorField:
+            'iOS build options for "$rootPath" have not been parsed yet. '
+            '$_fileIssueMessage',
+      };
+    }
+    if (!validOptions.configurations.contains(configuration) ||
+        !validOptions.targets.contains(target)) {
+      return <String, String?>{
+        kErrorField:
+            'Unknown iOS build configuration ($configuration) or target ($target) for "$rootPath". '
+            '$_fileIssueMessage',
+      };
+    }
     return _runFlutterCommand(
       <String>[
         'analyze',
@@ -230,8 +318,8 @@
         '--output-universal-link-settings',
         '--configuration=$configuration',
         '--target=$target',
-        rootPath,
       ],
+      workingDirectory: rootPath,
       outputMatcher: _outputFilePathRegex,
       ide: ide,
       suppressAnalytics: suppressAnalytics,
diff --git a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart
index 4e3a7b0..6d62b38 100644
--- a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart
+++ b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart
@@ -9,9 +9,28 @@
   factory UniversalLinkSettings.fromJson(String json) =>
       UniversalLinkSettings._(jsonDecode(json));
 
+  /// Used when the server can't retrieve universal link settings.
+  ///
+  /// The input needs to be in json format from DevTools server response.
+  factory UniversalLinkSettings.fromErrorJson(String json) {
+    final jsonObject = jsonDecode(json) as Map<String, Object?>;
+    final message = jsonObject[_kErrorKey]! as String;
+    return UniversalLinkSettings.error(message);
+  }
+
+  /// Used when the server can't retrieve universal link settings.
+  factory UniversalLinkSettings.error(String message) =>
+      UniversalLinkSettings._({
+        _kBundleIdentifierKey: '',
+        _kTeamIdentifierKey: '',
+        _kAssociatedDomainsKey: const <String>[],
+        _kErrorKey: message,
+      });
+
   static const _kBundleIdentifierKey = 'bundleIdentifier';
   static const _kTeamIdentifierKey = 'teamIdentifier';
   static const _kAssociatedDomainsKey = 'associatedDomains';
+  static const _kErrorKey = 'error';
 
   /// Used when the the server can't retrieve universal link settings.
   static const empty = UniversalLinkSettings._({
@@ -30,4 +49,7 @@
   /// The associated domains of the iOS build of this Flutter project.
   List<String> get associatedDomains =>
       (_json[_kAssociatedDomainsKey] as List).cast<String>().toList();
+
+  /// The error message when requesting universal link settings.
+  String? get error => _json[_kErrorKey] as String?;
 }
diff --git a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart
index fb36b12..5483a19 100644
--- a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart
+++ b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart
@@ -14,6 +14,7 @@
     late Directory tmpDir;
 
     setUp(() {
+      DeeplinkManager.clearBuildOptionsCache();
       manager = StubbedDeeplinkManager();
       tmpDir = Directory.current.createTempSync();
     });
@@ -33,12 +34,8 @@
       manager.expectedCommands.add(
         TestCommand(
           executable: manager.mockedFlutterBinary,
-          arguments: <String>[
-            'analyze',
-            '--android',
-            '--list-build-variants',
-            projectRoot,
-          ],
+          arguments: <String>['analyze', '--android', '--list-build-variants'],
+          workingDirectory: projectRoot,
           result: ProcessResult(0, 0, r'''
 Running Gradle task 'printBuildVariants'...                        10.4s
 ["debug","release","profile"]
@@ -66,8 +63,8 @@
               'analyze',
               '--android',
               '--list-build-variants',
-              projectRoot,
             ],
+            workingDirectory: projectRoot,
             ide: 'VS-Code',
             suppressAnalytics: true,
             result: ProcessResult(0, 0, r'''
@@ -97,8 +94,8 @@
               'analyze',
               '--android',
               '--list-build-variants',
-              projectRoot,
             ],
+            workingDirectory: projectRoot,
             result: ProcessResult(0, 1, '', 'unknown error'),
           ),
         );
@@ -118,7 +115,16 @@
       const buildVariant = 'someVariant';
       final jsonFile = File('${tmpDir.path}/some-output.json');
       jsonFile.writeAsStringSync(json);
-      manager.expectedCommands.add(
+      manager.expectedCommands.addAll([
+        TestCommand(
+          executable: manager.mockedFlutterBinary,
+          arguments: <String>['analyze', '--android', '--list-build-variants'],
+          workingDirectory: projectRoot,
+          result: ProcessResult(0, 0, '''
+Running Gradle task 'printBuildVariants'...                        10.4s
+["$buildVariant"]
+            ''', ''),
+        ),
         TestCommand(
           executable: manager.mockedFlutterBinary,
           arguments: <String>[
@@ -126,14 +132,15 @@
             '--android',
             '--output-app-link-settings',
             '--build-variant=$buildVariant',
-            projectRoot,
           ],
+          workingDirectory: projectRoot,
           result: ProcessResult(0, 0, '''
 Running Gradle task 'printBuildVariants'...                        10.4s
 result saved in ${jsonFile.absolute.path}
             ''', ''),
         ),
-      );
+      ]);
+      await manager.getAndroidBuildVariants(rootPath: projectRoot);
       final response = await manager.getAndroidAppLinkSettings(
         buildVariant: buildVariant,
         rootPath: projectRoot,
@@ -143,6 +150,59 @@
     });
 
     test(
+      'getAndroidAppLinkSettings reuses cached variants across instances',
+      () async {
+        const projectRoot = '/abc';
+        const json = '"some json"';
+        const buildVariant = 'release';
+        final jsonFile = File('${tmpDir.path}/some-output.json');
+        jsonFile.writeAsStringSync(json);
+
+        manager.expectedCommands.add(
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>[
+              'analyze',
+              '--android',
+              '--list-build-variants',
+            ],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, '''
+Running Gradle task 'printBuildVariants'...                        10.4s
+["debug","release","profile"]
+            ''', ''),
+          ),
+        );
+        await manager.getAndroidBuildVariants(rootPath: projectRoot);
+
+        final secondManager = StubbedDeeplinkManager()
+          ..expectedCommands.add(
+            TestCommand(
+              executable: manager.mockedFlutterBinary,
+              arguments: <String>[
+                'analyze',
+                '--android',
+                '--output-app-link-settings',
+                '--build-variant=$buildVariant',
+              ],
+              workingDirectory: '$projectRoot/',
+              result: ProcessResult(0, 0, '''
+Running Gradle task 'printBuildVariants'...                        10.4s
+result saved in ${jsonFile.absolute.path}
+            ''', ''),
+            ),
+          );
+        final response = await secondManager.getAndroidAppLinkSettings(
+          buildVariant: buildVariant,
+          rootPath: '$projectRoot/',
+        );
+        expect(secondManager.expectedCommands, isEmpty);
+        expect(response[DeeplinkManager.kErrorField], isNull);
+        expect(response[DeeplinkManager.kOutputJsonField], json);
+      },
+    );
+
+    test(
       'getIosUniversalLinkSettings calls flutter command correctly',
       () async {
         const projectRoot = '/abc';
@@ -151,7 +211,15 @@
         const target = 'someTarget';
         final jsonFile = File('${tmpDir.path}/some-output.json');
         jsonFile.writeAsStringSync(json);
-        manager.expectedCommands.add(
+        manager.expectedCommands.addAll([
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>['analyze', '--ios', '--list-build-options'],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, '''
+{"configurations":["$configuration"],"targets":["$target"]}
+            ''', ''),
+          ),
           TestCommand(
             executable: manager.mockedFlutterBinary,
             arguments: <String>[
@@ -160,14 +228,15 @@
               '--output-universal-link-settings',
               '--configuration=$configuration',
               '--target=$target',
-              projectRoot,
             ],
+            workingDirectory: projectRoot,
             result: ProcessResult(0, 0, '''
 Running Gradle task 'printBuildVariants'...                        10.4s
 result saved in ${jsonFile.absolute.path}
             ''', ''),
           ),
-        );
+        ]);
+        await manager.getIosBuildOptions(rootPath: projectRoot);
         final response = await manager.getIosUniversalLinkSettings(
           configuration: configuration,
           target: target,
@@ -183,12 +252,8 @@
       manager.expectedCommands.add(
         TestCommand(
           executable: manager.mockedFlutterBinary,
-          arguments: <String>[
-            'analyze',
-            '--ios',
-            '--list-build-options',
-            projectRoot,
-          ],
+          arguments: <String>['analyze', '--ios', '--list-build-options'],
+          workingDirectory: projectRoot,
           result: ProcessResult(0, 0, r'''
 {"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]}
             ''', ''),
@@ -201,6 +266,171 @@
         '{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]}',
       );
     });
+
+    test(
+      'returns error when project build options have not been parsed yet',
+      () async {
+        const projectRoot = '/unparsed_project';
+        final androidResponse = await manager.getAndroidAppLinkSettings(
+          rootPath: projectRoot,
+          buildVariant: 'debug',
+        );
+        expect(
+          androidResponse[DeeplinkManager.kErrorField],
+          allOf(
+            contains('have not been parsed yet'),
+            contains('https://github.com/flutter/devtools/issues'),
+          ),
+        );
+
+        final iosResponse = await manager.getIosUniversalLinkSettings(
+          rootPath: projectRoot,
+          configuration: 'Debug',
+          target: 'Runner',
+        );
+        expect(
+          iosResponse[DeeplinkManager.kErrorField],
+          allOf(
+            contains('have not been parsed yet'),
+            contains('https://github.com/flutter/devtools/issues'),
+          ),
+        );
+      },
+    );
+
+    test(
+      'rejects buildVariant not in available Android build variants',
+      () async {
+        const projectRoot = '/abc';
+        manager.expectedCommands.add(
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>[
+              'analyze',
+              '--android',
+              '--list-build-variants',
+            ],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, r'''
+Running Gradle task 'printBuildVariants'...                        10.4s
+["debug","release","profile"]
+            ''', ''),
+          ),
+        );
+        await manager.getAndroidBuildVariants(rootPath: projectRoot);
+
+        for (final invalidVariant in <String>[
+          'nonExistentVariant',
+          '"& calc & echo pwned > MARKER.txt &"',
+          'debug --help',
+        ]) {
+          final response = await manager.getAndroidAppLinkSettings(
+            rootPath: projectRoot,
+            buildVariant: invalidVariant,
+          );
+          expect(
+            response[DeeplinkManager.kErrorField],
+            allOf(
+              contains('Unknown Android build variant'),
+              contains('https://github.com/flutter/devtools/issues'),
+            ),
+          );
+        }
+      },
+    );
+
+    test(
+      'rejects configuration or target not in available iOS build options',
+      () async {
+        const projectRoot = '/abc';
+        manager.expectedCommands.add(
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>['analyze', '--ios', '--list-build-options'],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, r'''
+{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]}
+            ''', ''),
+          ),
+        );
+        await manager.getIosBuildOptions(rootPath: projectRoot);
+
+        final badConfigResponse = await manager.getIosUniversalLinkSettings(
+          rootPath: projectRoot,
+          configuration: '"& calc & echo pwned > MARKER.txt &"',
+          target: 'Runner',
+        );
+        expect(
+          badConfigResponse[DeeplinkManager.kErrorField],
+          allOf(
+            contains('Unknown iOS build configuration'),
+            contains('https://github.com/flutter/devtools/issues'),
+          ),
+        );
+
+        final badTargetResponse = await manager.getIosUniversalLinkSettings(
+          rootPath: projectRoot,
+          configuration: 'Debug',
+          target: 'Runner --help',
+        );
+        expect(
+          badTargetResponse[DeeplinkManager.kErrorField],
+          allOf(
+            contains('Unknown iOS build configuration'),
+            contains('https://github.com/flutter/devtools/issues'),
+          ),
+        );
+      },
+    );
+
+    test('returns error when workingDirectory does not exist', () async {
+      final realManager = DeeplinkManager();
+      final response = await realManager.getAndroidBuildVariants(
+        rootPath: '${tmpDir.path}/non_existent_dir_"&calc',
+      );
+      expect(response[DeeplinkManager.kErrorField], isNotNull);
+    });
+
+    test(
+      'returns error when parsing Android build variants or iOS build options fails',
+      () async {
+        const projectRoot = '/abc';
+        manager.expectedCommands.addAll([
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>[
+              'analyze',
+              '--android',
+              '--list-build-variants',
+            ],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, '[invalid json]', ''),
+          ),
+          TestCommand(
+            executable: manager.mockedFlutterBinary,
+            arguments: <String>['analyze', '--ios', '--list-build-options'],
+            workingDirectory: projectRoot,
+            result: ProcessResult(0, 0, '{invalid json}', ''),
+          ),
+        ]);
+
+        final androidResponse = await manager.getAndroidBuildVariants(
+          rootPath: projectRoot,
+        );
+        expect(
+          androidResponse[DeeplinkManager.kErrorField],
+          contains('Failed to parse Android build variants'),
+        );
+
+        final iosResponse = await manager.getIosBuildOptions(
+          rootPath: projectRoot,
+        );
+        expect(
+          iosResponse[DeeplinkManager.kErrorField],
+          contains('Failed to parse iOS build options'),
+        );
+      },
+    );
   });
 }
 
@@ -215,8 +445,9 @@
   Future<ProcessResult> runProcess(
     String executable, {
     required List<String> arguments,
-    String? ide,
-    bool suppressAnalytics = false,
+    required String workingDirectory,
+    required String? ide,
+    required bool suppressAnalytics,
   }) async {
     if (expectedCommands.isNotEmpty) {
       final expectedCommand = expectedCommands.removeAt(0);
@@ -228,6 +459,7 @@
         ),
         isTrue,
       );
+      expect(workingDirectory, expectedCommand.workingDirectory);
       expect(ide, expectedCommand.ide);
       expect(suppressAnalytics, expectedCommand.suppressAnalytics);
       return expectedCommand.result;
@@ -240,18 +472,20 @@
   const TestCommand({
     required this.executable,
     required this.arguments,
+    required this.workingDirectory,
     this.ide,
     this.suppressAnalytics = false,
     required this.result,
   });
   final String executable;
   final List<String> arguments;
+  final String workingDirectory;
   final String? ide;
   final bool suppressAnalytics;
   final ProcessResult result;
 
   @override
   String toString() {
-    return '"$executable ${arguments.join(' ')}"';
+    return '"$executable ${arguments.join(' ')}" (workingDirectory: $workingDirectory)';
   }
 }
diff --git a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart
index ec15bcd..d53955c 100644
--- a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart
+++ b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart
@@ -45,6 +45,24 @@
       expect(settings.bundleIdentifier, 'com.example.app');
       expect(settings.teamIdentifier, isEmpty);
       expect(settings.associatedDomains, ['applinks:example.com']);
+      expect(settings.error, isNull);
+    });
+
+    test('parses error json correctly', () {
+      const json = '{"error": "Failed to load iOS settings"}';
+      final settings = UniversalLinkSettings.fromErrorJson(json);
+      expect(settings.bundleIdentifier, isEmpty);
+      expect(settings.teamIdentifier, isEmpty);
+      expect(settings.associatedDomains, isEmpty);
+      expect(settings.error, 'Failed to load iOS settings');
+    });
+
+    test('creates error settings correctly', () {
+      final settings = UniversalLinkSettings.error('Some error');
+      expect(settings.bundleIdentifier, isEmpty);
+      expect(settings.teamIdentifier, isEmpty);
+      expect(settings.associatedDomains, isEmpty);
+      expect(settings.error, 'Some error');
     });
   });
 }