// Copyright 2014 The Flutter 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:convert'; import 'dart:io' as io; import 'package:file/file.dart'; import 'package:flutter_tools/src/android/gradle_utils.dart' show getGradlewFileName; import 'package:flutter_tools/src/base/io.dart'; import 'package:xml/xml.dart'; import '../src/common.dart'; import 'test_utils.dart'; final XmlElement deeplinkFlagMetaData = XmlElement(XmlName('meta-data'), [ XmlAttribute(XmlName('name', 'android'), 'flutter_deeplinking_enabled'), XmlAttribute(XmlName('value', 'android'), 'true'), ]); final XmlElement pureHttpIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('host', 'android'), 'pure-http.com'), ]), ], ); final XmlElement nonHttpIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'custom'), XmlAttribute(XmlName('host', 'android'), 'custom.com'), ]), ], ); final XmlElement hybridIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'custom'), XmlAttribute(XmlName('host', 'android'), 'hybrid.com'), ]), XmlElement(XmlName('data'), [XmlAttribute(XmlName('scheme', 'android'), 'http')]), ], ); final XmlElement nonAutoVerifyIntentFilter = XmlElement( XmlName('intent-filter'), [], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('host', 'android'), 'non-auto-verify.com'), ]), ], ); final XmlElement nonActionIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('host', 'android'), 'non-action.com'), ]), ], ); final XmlElement nonDefaultCategoryIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('host', 'android'), 'non-default-category.com'), ]), ], ); final XmlElement nonBrowsableCategoryIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('host', 'android'), 'non-browsable-category.com'), ]), ], ); final XmlElement nonSchemeCategoryIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('host', 'android'), 'non-browsable-category.com'), ]), ], ); final XmlElement nonHostCategoryIntentFilter = XmlElement( XmlName('intent-filter'), [XmlAttribute(XmlName('autoVerify', 'android'), 'true')], [ XmlElement(XmlName('action'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.action.VIEW'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.DEFAULT'), ]), XmlElement(XmlName('category'), [ XmlAttribute(XmlName('name', 'android'), 'android.intent.category.BROWSABLE'), ]), XmlElement(XmlName('data'), [ XmlAttribute(XmlName('scheme', 'android'), 'http'), XmlAttribute(XmlName('path', 'android'), '/path1'), ]), ], ); void main() { late Directory tempDir; setUp(() async { tempDir = createResolvedTempDirectorySync('run_test.'); }); tearDown(() async { tryToDelete(tempDir); }); void testDeeplink( dynamic deeplink, String? scheme, String? host, String path, { required bool hasAutoVerify, required bool hasActionView, required bool hasDefaultCategory, required bool hasBrowsableCategory, }) { deeplink as Map; expect(deeplink['scheme'], scheme); expect(deeplink['host'], host); expect(deeplink['path'], path); final Map intentFilterCheck = deeplink['intentFilterCheck'] as Map; expect(intentFilterCheck['hasAutoVerify'], hasAutoVerify); expect(intentFilterCheck['hasActionView'], hasActionView); expect(intentFilterCheck['hasDefaultCategory'], hasDefaultCategory); expect(intentFilterCheck['hasBrowsableCategory'], hasBrowsableCategory); } testWithoutContext( 'gradle task outputsAppLinkSettings works when a project has app links', () async { // Create a new flutter project. ProcessResult result = await processManager.run([ flutterBin, 'create', tempDir.path, '--project-name=testapp', ], workingDirectory: tempDir.path); expect(result, const ProcessResultMatcher()); // Adds intent filters for app links final String androidManifestPath = fileSystem.path.join( tempDir.path, 'android', 'app', 'src', 'main', 'AndroidManifest.xml', ); final io.File androidManifestFile = io.File(androidManifestPath); final XmlDocument androidManifest = XmlDocument.parse(androidManifestFile.readAsStringSync()); final XmlElement activity = androidManifest.findAllElements('activity').first; activity.children.add(deeplinkFlagMetaData); activity.children.add(pureHttpIntentFilter); activity.children.add(nonHttpIntentFilter); activity.children.add(hybridIntentFilter); activity.children.add(nonAutoVerifyIntentFilter); activity.children.add(nonActionIntentFilter); activity.children.add(nonDefaultCategoryIntentFilter); activity.children.add(nonBrowsableCategoryIntentFilter); activity.children.add(nonSchemeCategoryIntentFilter); activity.children.add(nonHostCategoryIntentFilter); androidManifestFile.writeAsStringSync(androidManifest.toString(), flush: true); // Ensure that gradle files exists from templates. result = await processManager.run([ flutterBin, 'build', 'apk', '--config-only', ], workingDirectory: tempDir.path); expect(result, const ProcessResultMatcher()); final Directory androidApp = tempDir.childDirectory('android'); final io.File fileDump = tempDir .childDirectory('build') .childDirectory('app') .childFile('app-link-settings-debug.json'); result = await processManager.run([ '.${platform.pathSeparator}${getGradlewFileName(platform)}', ...getLocalEngineArguments(), '-q', // quiet output. '-PoutputPath=${fileDump.path}', 'outputDebugAppLinkSettings', ], workingDirectory: androidApp.path); expect(result, const ProcessResultMatcher()); expect(fileDump.existsSync(), true); final Map json = jsonDecode(fileDump.readAsStringSync()) as Map; expect(json['applicationId'], 'com.example.testapp'); expect(json['deeplinkingFlagEnabled'], true); final List deeplinks = json['deeplinks']! as List; expect(deeplinks.length, 10); testDeeplink( deeplinks[0], 'http', 'pure-http.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[1], 'custom', 'custom.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[2], 'custom', 'hybrid.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[3], 'http', 'hybrid.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[4], 'http', 'non-auto-verify.com', '.*', hasAutoVerify: false, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[5], 'http', 'non-action.com', '.*', hasAutoVerify: true, hasActionView: false, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[6], 'http', 'non-default-category.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: false, hasBrowsableCategory: true, ); testDeeplink( deeplinks[7], 'http', 'non-browsable-category.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: false, ); testDeeplink( deeplinks[8], null, 'non-browsable-category.com', '.*', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); testDeeplink( deeplinks[9], 'http', null, '/path1', hasAutoVerify: true, hasActionView: true, hasDefaultCategory: true, hasBrowsableCategory: true, ); }, ); testWithoutContext( 'gradle task outputsAppLinkSettings works when a project does not have app link and the flutter_deeplinking_enabled flag', () async { // Create a new flutter project. ProcessResult result = await processManager.run([ flutterBin, 'create', tempDir.path, '--project-name=testapp', ], workingDirectory: tempDir.path); expect(result, const ProcessResultMatcher()); // Ensure that gradle files exists from templates. result = await processManager.run([ flutterBin, 'build', 'apk', '--config-only', ], workingDirectory: tempDir.path); expect(result, const ProcessResultMatcher()); final Directory androidApp = tempDir.childDirectory('android'); final io.File fileDump = tempDir .childDirectory('build') .childDirectory('app') .childFile('app-link-settings-debug.json'); result = await processManager.run([ '.${platform.pathSeparator}${getGradlewFileName(platform)}', ...getLocalEngineArguments(), '-q', // quiet output. '-PoutputPath=${fileDump.path}', 'outputDebugAppLinkSettings', ], workingDirectory: androidApp.path); expect(result, const ProcessResultMatcher()); expect(fileDump.existsSync(), true); final Map json = jsonDecode(fileDump.readAsStringSync()) as Map; expect(json['applicationId'], 'com.example.testapp'); expect(json['deeplinkingFlagEnabled'], false); final List deeplinks = json['deeplinks']! as List; expect(deeplinks.length, 0); }, ); }